From a6fc40d7f0a306a69a56983f79d0d3e9a1034b4e Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Wed, 24 Jan 2024 15:22:26 +0100 Subject: [PATCH 001/136] chore: added abstract layer for push notifications --- Core/Core.xcodeproj/project.pbxproj | 4 ++ .../Configuration/Config/BrazeConfig.swift | 32 +++++++++ Core/Core/Configuration/Config/Config.swift | 1 + .../CoreTests/Configuration/ConfigTests.swift | 10 +++ OpenEdX.xcodeproj/project.pbxproj | 64 ++++++++++++++++- OpenEdX/AppDelegate.swift | 14 ++++ OpenEdX/DI/AppAssembly.swift | 6 ++ .../AnalyticsManager}/AnalyticsManager.swift | 0 .../MainScreenAnalytics.swift | 0 .../Listeners/BrazeListener.swift | 14 ++++ .../Listeners/FCMListener.swift | 14 ++++ .../Providers/BrazeProvider.swift | 17 +++++ .../Providers/FCMProvider.swift | 17 +++++ .../PushNotificationsManager.swift | 68 +++++++++++++++++++ 14 files changed, 259 insertions(+), 2 deletions(-) create mode 100644 Core/Core/Configuration/Config/BrazeConfig.swift rename OpenEdX/{ => Managers/AnalyticsManager}/AnalyticsManager.swift (100%) rename OpenEdX/{ => Managers/AnalyticsManager}/MainScreenAnalytics.swift (100%) create mode 100644 OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift create mode 100644 OpenEdX/Managers/PushNotificationsManager/Listeners/FCMListener.swift create mode 100644 OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift create mode 100644 OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift create mode 100644 OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 8b9dae2f0..0f61e2d47 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -125,6 +125,7 @@ 07DDFCBD29A780BB00572595 /* UINavigationController+Animation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07DDFCBC29A780BB00572595 /* UINavigationController+Animation.swift */; }; 07E0939F2B308D2800F1E4B2 /* Data_Certificate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E0939E2B308D2800F1E4B2 /* Data_Certificate.swift */; }; A53A32352B233DEC005FE38A /* ThemeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A32342B233DEC005FE38A /* ThemeConfig.swift */; }; + A5F4E7B52B61544A00ACD166 /* BrazeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */; }; BA30427F2B20B320009B64B7 /* SocialAuthError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA30427D2B20B299009B64B7 /* SocialAuthError.swift */; }; BA593F1C2AF8E498009ADB51 /* ScrollSlidingTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA593F1B2AF8E498009ADB51 /* ScrollSlidingTabBar.swift */; }; BA593F1E2AF8E4A0009ADB51 /* FrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA593F1D2AF8E4A0009ADB51 /* FrameReader.swift */; }; @@ -294,6 +295,7 @@ 60153262DBC2F9E660D7E11B /* Pods-App-Core.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.release.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.release.xcconfig"; sourceTree = ""; }; 9D5B06CAA99EA5CD49CBE2BB /* Pods-App-Core.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debugdev.xcconfig"; sourceTree = ""; }; A53A32342B233DEC005FE38A /* ThemeConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThemeConfig.swift; sourceTree = ""; }; + A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrazeConfig.swift; sourceTree = ""; }; BA30427D2B20B299009B64B7 /* SocialAuthError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SocialAuthError.swift; sourceTree = ""; }; BA593F1B2AF8E498009ADB51 /* ScrollSlidingTabBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollSlidingTabBar.swift; sourceTree = ""; }; BA593F1D2AF8E4A0009ADB51 /* FrameReader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrameReader.swift; sourceTree = ""; }; @@ -749,6 +751,7 @@ 0604C9A92B22FACF00AD5DBF /* UIComponentsConfig.swift */, 0727876F28D23411002E9142 /* Config.swift */, DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */, + A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */, DBF6F2492B0380E00098414B /* FeaturesConfig.swift */, DBF6F2452B01DAFE0098414B /* AgreementConfig.swift */, BAFB99812B0E2354007D09F9 /* FacebookConfig.swift */, @@ -1054,6 +1057,7 @@ DBF6F2412B014ADA0098414B /* FirebaseConfig.swift in Sources */, 072787B628D37A0E002E9142 /* Validator.swift in Sources */, 0236961D28F9A2D200EEF206 /* Data_AuthResponse.swift in Sources */, + A5F4E7B52B61544A00ACD166 /* BrazeConfig.swift in Sources */, 02AFCC182AEFDB24000360F0 /* ThirdPartyMailClient.swift in Sources */, 0233D5712AF13EC800BAC8BD /* SelectMailClientView.swift in Sources */, BAFB99842B0E282E007D09F9 /* MicrosoftConfig.swift in Sources */, diff --git a/Core/Core/Configuration/Config/BrazeConfig.swift b/Core/Core/Configuration/Config/BrazeConfig.swift new file mode 100644 index 000000000..8410bf3f8 --- /dev/null +++ b/Core/Core/Configuration/Config/BrazeConfig.swift @@ -0,0 +1,32 @@ +// +// BrazeConfig.swift +// Core +// +// Created by Anton Yarmolenka on 24/01/2024. +// + +import Foundation + +private enum BrazeKeys: String { + case enabled = "ENABLED" + case pushNotificationsEnabled = "PUSH_NOTIFICATIONS_ENABLED" +} + +public final class BrazeConfig: NSObject { + public var enabled: Bool = false + public var pushNotificationsEnabled: Bool = false + + init(dictionary: [String: AnyObject]) { + super.init() + enabled = dictionary[BrazeKeys.enabled.rawValue] as? Bool == true + let pushNotificationsEnabled = dictionary[BrazeKeys.pushNotificationsEnabled.rawValue] as? Bool ?? false + self.pushNotificationsEnabled = enabled && pushNotificationsEnabled + } +} + +private let brazeKey = "BRAZE" +extension Config { + public var braze: BrazeConfig { + BrazeConfig(dictionary: self[brazeKey] as? [String: AnyObject] ?? [:]) + } +} diff --git a/Core/Core/Configuration/Config/Config.swift b/Core/Core/Configuration/Config/Config.swift index 7fcbe1c94..5c1693c97 100644 --- a/Core/Core/Configuration/Config/Config.swift +++ b/Core/Core/Configuration/Config/Config.swift @@ -25,6 +25,7 @@ public protocol ConfigProtocol { var theme: ThemeConfig { get } var uiComponents: UIComponentsConfig { get } var discovery: DiscoveryConfig { get } + var braze: BrazeConfig { get } } public enum TokenType: String { diff --git a/Core/CoreTests/Configuration/ConfigTests.swift b/Core/CoreTests/Configuration/ConfigTests.swift index 32638e484..e9b8a0ce7 100644 --- a/Core/CoreTests/Configuration/ConfigTests.swift +++ b/Core/CoreTests/Configuration/ConfigTests.swift @@ -52,6 +52,10 @@ class ConfigTests: XCTestCase { ], "APPLE_SIGNIN": [ "ENABLED": true + ], + "BRAZE": [ + "ENABLED": true, + "PUSH_NOTIFICATIONS_ENABLED": true ] ] @@ -115,4 +119,10 @@ class ConfigTests: XCTestCase { XCTAssertTrue(config.appleSignIn.enabled) } + + func testBrazeConfigInitialization() { + let config = Config(properties: properties) + + XCTAssertTrue(config.braze.pushNotificationsEnabled) + } } diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 072473af5..b02ea1814 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -45,6 +45,11 @@ 07D5DA3E28D075AB00752FD9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 07D5DA3D28D075AB00752FD9 /* Assets.xcassets */; }; 07D5DA4128D075AB00752FD9 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 07D5DA3F28D075AB00752FD9 /* LaunchScreen.storyboard */; }; 95C140F3BDF778364986E83B /* Pods_App_OpenEdX.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F138C15C3A2515F8F94DAA8B /* Pods_App_OpenEdX.framework */; }; + A500668B2B613ED10024680B /* PushNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500668A2B613ED10024680B /* PushNotificationsManager.swift */; }; + A500668D2B6143000024680B /* FCMProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500668C2B6143000024680B /* FCMProvider.swift */; }; + A50066912B61467B0024680B /* BrazeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50066902B61467B0024680B /* BrazeProvider.swift */; }; + A50066932B614DCD0024680B /* FCMListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50066922B614DCD0024680B /* FCMListener.swift */; }; + A50066952B614DEF0024680B /* BrazeListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50066942B614DEF0024680B /* BrazeListener.swift */; }; BA3042792B1F7147009B64B7 /* MSAL in Frameworks */ = {isa = PBXBuildFile; productRef = BA3042782B1F7147009B64B7 /* MSAL */; }; E0D6E6A32B1626B10089F9C9 /* Theme.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0D6E6A22B1626B10089F9C9 /* Theme.framework */; }; E0D6E6A42B1626D60089F9C9 /* Theme.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E0D6E6A22B1626B10089F9C9 /* Theme.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -115,6 +120,11 @@ 6F54C19C823A769E18923FA8 /* Pods-App-OpenEdX.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugstage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugstage.xcconfig"; sourceTree = ""; }; 8284179FC05AEE2591573E20 /* Pods-App-OpenEdX.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugdev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugdev.xcconfig"; sourceTree = ""; }; A24D6A8E1BC4DF46AD68904C /* Pods-App-OpenEdX.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releaseprod.xcconfig"; sourceTree = ""; }; + A500668A2B613ED10024680B /* PushNotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationsManager.swift; sourceTree = ""; }; + A500668C2B6143000024680B /* FCMProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FCMProvider.swift; sourceTree = ""; }; + A50066902B61467B0024680B /* BrazeProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrazeProvider.swift; sourceTree = ""; }; + A50066922B614DCD0024680B /* FCMListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FCMListener.swift; sourceTree = ""; }; + A50066942B614DEF0024680B /* BrazeListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrazeListener.swift; sourceTree = ""; }; A89AD827F52CF6A6B903606E /* Pods-App-OpenEdX.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasestage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasestage.xcconfig"; sourceTree = ""; }; BB08ACD2CCA33D6DDDDD31B4 /* Pods-App-OpenEdX.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasedev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasedev.xcconfig"; sourceTree = ""; }; E0D6E6A22B1626B10089F9C9 /* Theme.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Theme.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -200,8 +210,7 @@ 07D5DA3428D075AA00752FD9 /* AppDelegate.swift */, 0770DE1628D080A1006D8A5D /* RouteController.swift */, 0770DE1F28D0858A006D8A5D /* Router.swift */, - 0298DF2F2A4EF7230023A257 /* AnalyticsManager.swift */, - 02F175302A4DA95B0019CD70 /* MainScreenAnalytics.swift */, + A50066882B613E800024680B /* Managers */, 0293A2012A6FC9E30090A336 /* Data */, 0727878C28D347B2002E9142 /* View */, 0770DE1A28D084BC006D8A5D /* DI */, @@ -248,6 +257,52 @@ path = Pods; sourceTree = ""; }; + A50066872B613E4B0024680B /* PushNotificationsManager */ = { + isa = PBXGroup; + children = ( + A500668A2B613ED10024680B /* PushNotificationsManager.swift */, + A50066962B614F0C0024680B /* Providers */, + A50066972B614F2B0024680B /* Listeners */, + ); + path = PushNotificationsManager; + sourceTree = ""; + }; + A50066882B613E800024680B /* Managers */ = { + isa = PBXGroup; + children = ( + A50066872B613E4B0024680B /* PushNotificationsManager */, + A50066892B613E990024680B /* AnalyticsManager */, + ); + path = Managers; + sourceTree = ""; + }; + A50066892B613E990024680B /* AnalyticsManager */ = { + isa = PBXGroup; + children = ( + 0298DF2F2A4EF7230023A257 /* AnalyticsManager.swift */, + 02F175302A4DA95B0019CD70 /* MainScreenAnalytics.swift */, + ); + path = AnalyticsManager; + sourceTree = ""; + }; + A50066962B614F0C0024680B /* Providers */ = { + isa = PBXGroup; + children = ( + A500668C2B6143000024680B /* FCMProvider.swift */, + A50066902B61467B0024680B /* BrazeProvider.swift */, + ); + path = Providers; + sourceTree = ""; + }; + A50066972B614F2B0024680B /* Listeners */ = { + isa = PBXGroup; + children = ( + A50066922B614DCD0024680B /* FCMListener.swift */, + A50066942B614DEF0024680B /* BrazeListener.swift */, + ); + path = Listeners; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -413,9 +468,11 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A500668B2B613ED10024680B /* PushNotificationsManager.swift in Sources */, 0293A2052A6FCD430090A336 /* CoursePersistence.swift in Sources */, 020CA5D92AA0A25300970AAF /* AppStorage.swift in Sources */, 0298DF302A4EF7230023A257 /* AnalyticsManager.swift in Sources */, + A50066912B61467B0024680B /* BrazeProvider.swift in Sources */, 0293A2072A6FCDA30090A336 /* DiscoveryPersistence.swift in Sources */, 07D5DA3528D075AA00752FD9 /* AppDelegate.swift in Sources */, 02F175312A4DA95B0019CD70 /* MainScreenAnalytics.swift in Sources */, @@ -423,11 +480,14 @@ 0770DE5028D0A707006D8A5D /* NetworkAssembly.swift in Sources */, 0293A2032A6FCA590090A336 /* CorePersistence.swift in Sources */, 0770DE1E28D084E8006D8A5D /* AppAssembly.swift in Sources */, + A50066932B614DCD0024680B /* FCMListener.swift in Sources */, + A500668D2B6143000024680B /* FCMProvider.swift in Sources */, 025AD4AC2A6FB95C00AB8FA7 /* DatabaseManager.swift in Sources */, 024E69202AEFC3FB00FA0B59 /* MainScreenViewModel.swift in Sources */, 0770DE2028D0858A006D8A5D /* Router.swift in Sources */, 0293A2092A6FCDE50090A336 /* DashboardPersistence.swift in Sources */, 0770DE1728D080A1006D8A5D /* RouteController.swift in Sources */, + A50066952B614DEF0024680B /* BrazeListener.swift in Sources */, 071009C928D1DB3F00344290 /* ScreenAssembly.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index 8b6b5bd1f..0a8a1c5b1 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -123,4 +123,18 @@ class AppDelegate: UIResponder, UIApplicationDelegate { window?.rootViewController = RouteController() } + // Push Notifications + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + let pushManager = Container.shared.resolve(PushNotificationsManager.self)! + pushManager.didRegisterForRemoteNotificationsWithDeviceToken(deviceToken: deviceToken) + } + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + let pushManager = Container.shared.resolve(PushNotificationsManager.self)! + pushManager.didFailToRegisterForRemoteNotificationsWithError(error: error) + } + func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { + let pushManager = Container.shared.resolve(PushNotificationsManager.self)! + pushManager.didReceiveRemoteNotification(userInfo: userInfo) + completionHandler(.newData) + } } diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index e5a491fc7..4e24e6924 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -157,6 +157,12 @@ class AppAssembly: Assembly { container.register(Validator.self) { _ in Validator() }.inObjectScope(.container) + + container.register(PushNotificationsManager.self) { r in + PushNotificationsManager( + config: r.resolve(ConfigProtocol.self)! + ) + }.inObjectScope(.container) } } // swiftlint:enable function_body_length diff --git a/OpenEdX/AnalyticsManager.swift b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift similarity index 100% rename from OpenEdX/AnalyticsManager.swift rename to OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift diff --git a/OpenEdX/MainScreenAnalytics.swift b/OpenEdX/Managers/AnalyticsManager/MainScreenAnalytics.swift similarity index 100% rename from OpenEdX/MainScreenAnalytics.swift rename to OpenEdX/Managers/AnalyticsManager/MainScreenAnalytics.swift diff --git a/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift b/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift new file mode 100644 index 000000000..a705953e0 --- /dev/null +++ b/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift @@ -0,0 +1,14 @@ +// +// BrazeListener.swift +// OpenEdX +// +// Created by Anton Yarmolenka on 24/01/2024. +// + +import Foundation + +class BrazeListener: PushNotificationsListener { + func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) { + + } +} diff --git a/OpenEdX/Managers/PushNotificationsManager/Listeners/FCMListener.swift b/OpenEdX/Managers/PushNotificationsManager/Listeners/FCMListener.swift new file mode 100644 index 000000000..3c0725e04 --- /dev/null +++ b/OpenEdX/Managers/PushNotificationsManager/Listeners/FCMListener.swift @@ -0,0 +1,14 @@ +// +// FCMListener.swift +// OpenEdX +// +// Created by Anton Yarmolenka on 24/01/2024. +// + +import Foundation + +class FCMListener: PushNotificationsListener { + func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) { + + } +} diff --git a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift new file mode 100644 index 000000000..043e7d62b --- /dev/null +++ b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift @@ -0,0 +1,17 @@ +// +// BrazeProvider.swift +// OpenEdX +// +// Created by Anton Yarmolenka on 24/01/2024. +// + +import Foundation + +class BrazeProvider: PushNotificationsProvider { + func didRegisterForRemoteNotificationsWithDeviceToken(deviceToken: Data) { + + } + func didFailToRegisterForRemoteNotificationsWithError(error: Error) { + + } +} diff --git a/OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift b/OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift new file mode 100644 index 000000000..739659e12 --- /dev/null +++ b/OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift @@ -0,0 +1,17 @@ +// +// FCMProvider.swift +// OpenEdX +// +// Created by Anton Yarmolenka on 24/01/2024. +// + +import Foundation + +class FCMProvider: PushNotificationsProvider { + func didRegisterForRemoteNotificationsWithDeviceToken(deviceToken: Data) { + + } + func didFailToRegisterForRemoteNotificationsWithError(error: Error) { + + } +} diff --git a/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift b/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift new file mode 100644 index 000000000..23e747ccf --- /dev/null +++ b/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift @@ -0,0 +1,68 @@ +// +// PushNotificationManager.swift +// OpenEdX +// +// Created by Anton Yarmolenka on 24/01/2024. +// + +import Foundation +import Core + +public protocol PushNotificationsProvider { + func didRegisterForRemoteNotificationsWithDeviceToken(deviceToken: Data) + func didFailToRegisterForRemoteNotificationsWithError(error: Error) +} + +protocol PushNotificationsListener { + func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) +} + +class PushNotificationsManager { + private var providers: [PushNotificationsProvider] = [] + private var listeners: [PushNotificationsListener] = [] + + // Init manager + public init(config: ConfigProtocol) { + self.providers = self.providersFor(config: config) + self.listeners = self.listenersFor(config: config) + } + + private func providersFor(config: ConfigProtocol) -> [PushNotificationsProvider] { + var rProviders: [PushNotificationsProvider] = [] + if config.firebase.cloudMessagingEnabled { + rProviders.append(FCMProvider()) + } + if config.braze.pushNotificationsEnabled { + rProviders.append(BrazeProvider()) + } + return rProviders + } + + private func listenersFor(config: ConfigProtocol) -> [PushNotificationsListener] { + var rListeners: [PushNotificationsListener] = [] + if config.firebase.cloudMessagingEnabled { + rListeners.append(FCMListener()) + } + if config.braze.pushNotificationsEnabled { + rListeners.append(BrazeListener()) + } + return rListeners + } + + // Proccess functions from app delegate + public func didRegisterForRemoteNotificationsWithDeviceToken(deviceToken: Data) { + for provider in providers { + provider.didRegisterForRemoteNotificationsWithDeviceToken(deviceToken: deviceToken) + } + } + public func didFailToRegisterForRemoteNotificationsWithError(error: Error) { + for provider in providers { + provider.didFailToRegisterForRemoteNotificationsWithError(error: error) + } + } + public func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) { + for listener in listeners { + listener.didReceiveRemoteNotification(userInfo: userInfo) + } + } +} From c87f7cb837eea0e9c00bbf2ceb86840c313decd2 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Fri, 26 Jan 2024 13:22:03 +0100 Subject: [PATCH 002/136] chore: abstract layer for deep linking --- Core/Core.xcodeproj/project.pbxproj | 4 ++ .../Configuration/Config/BranchConfig.swift | 31 ++++++++ Core/Core/Configuration/Config/Config.swift | 3 +- .../CoreTests/Configuration/ConfigTests.swift | 11 +++ OpenEdX.xcodeproj/project.pbxproj | 32 +++++++++ OpenEdX/AppDelegate.swift | 40 +++++++++-- OpenEdX/DI/AppAssembly.swift | 6 ++ .../DeepLinkManager/BranchService.swift | 35 +++++++++ .../DeepLinkManager/DeepLinkManager.swift | 72 +++++++++++++++++++ .../DeepLinkManager/Link/DeepLink.swift | 18 +++++ .../DeepLinkManager/Link/PushLink.swift | 30 ++++++++ .../Listeners/BrazeListener.swift | 4 +- .../Listeners/FCMListener.swift | 4 +- .../PushNotificationsManager.swift | 28 ++++++++ 14 files changed, 304 insertions(+), 14 deletions(-) create mode 100644 Core/Core/Configuration/Config/BranchConfig.swift create mode 100644 OpenEdX/Managers/DeepLinkManager/BranchService.swift create mode 100644 OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift create mode 100644 OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift create mode 100644 OpenEdX/Managers/DeepLinkManager/Link/PushLink.swift diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 0f61e2d47..4382f8035 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -125,6 +125,7 @@ 07DDFCBD29A780BB00572595 /* UINavigationController+Animation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07DDFCBC29A780BB00572595 /* UINavigationController+Animation.swift */; }; 07E0939F2B308D2800F1E4B2 /* Data_Certificate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E0939E2B308D2800F1E4B2 /* Data_Certificate.swift */; }; A53A32352B233DEC005FE38A /* ThemeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A32342B233DEC005FE38A /* ThemeConfig.swift */; }; + A595689B2B6173DF00ED4F90 /* BranchConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A595689A2B6173DF00ED4F90 /* BranchConfig.swift */; }; A5F4E7B52B61544A00ACD166 /* BrazeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */; }; BA30427F2B20B320009B64B7 /* SocialAuthError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA30427D2B20B299009B64B7 /* SocialAuthError.swift */; }; BA593F1C2AF8E498009ADB51 /* ScrollSlidingTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA593F1B2AF8E498009ADB51 /* ScrollSlidingTabBar.swift */; }; @@ -295,6 +296,7 @@ 60153262DBC2F9E660D7E11B /* Pods-App-Core.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.release.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.release.xcconfig"; sourceTree = ""; }; 9D5B06CAA99EA5CD49CBE2BB /* Pods-App-Core.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debugdev.xcconfig"; sourceTree = ""; }; A53A32342B233DEC005FE38A /* ThemeConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThemeConfig.swift; sourceTree = ""; }; + A595689A2B6173DF00ED4F90 /* BranchConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BranchConfig.swift; sourceTree = ""; }; A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrazeConfig.swift; sourceTree = ""; }; BA30427D2B20B299009B64B7 /* SocialAuthError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SocialAuthError.swift; sourceTree = ""; }; BA593F1B2AF8E498009ADB51 /* ScrollSlidingTabBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollSlidingTabBar.swift; sourceTree = ""; }; @@ -752,6 +754,7 @@ 0727876F28D23411002E9142 /* Config.swift */, DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */, A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */, + A595689A2B6173DF00ED4F90 /* BranchConfig.swift */, DBF6F2492B0380E00098414B /* FeaturesConfig.swift */, DBF6F2452B01DAFE0098414B /* AgreementConfig.swift */, BAFB99812B0E2354007D09F9 /* FacebookConfig.swift */, @@ -1030,6 +1033,7 @@ 021D925728DCF12900ACC565 /* AlertView.swift in Sources */, 027BD3A82909474200392132 /* KeyboardAvoidingViewController.swift in Sources */, 02E93F852AEBAEBC006C4750 /* AppReviewViewModel.swift in Sources */, + A595689B2B6173DF00ED4F90 /* BranchConfig.swift in Sources */, 0770DE2528D08FBA006D8A5D /* CoreStorage.swift in Sources */, BA8FA6612AD5974300EA029A /* AppleAuthProvider.swift in Sources */, BA8FA6702AD59EA300EA029A /* MicrosoftAuthProvider.swift in Sources */, diff --git a/Core/Core/Configuration/Config/BranchConfig.swift b/Core/Core/Configuration/Config/BranchConfig.swift new file mode 100644 index 000000000..33440a3a0 --- /dev/null +++ b/Core/Core/Configuration/Config/BranchConfig.swift @@ -0,0 +1,31 @@ +// +// BranchConfig.swift +// Core +// +// Created by Anton Yarmolenka on 24/01/2024. +// + +import Foundation + +private enum BranchKeys: String { + case enabled = "ENABLED" + case key = "KEY" +} + +public final class BranchConfig: NSObject { + public var enabled: Bool = false + public var key: String? + + init(dictionary: [String: AnyObject]) { + super.init() + enabled = dictionary[BranchKeys.enabled.rawValue] as? Bool == true + key = dictionary[BranchKeys.key.rawValue] as? String + } +} + +private let branchKey = "BRANCH" +extension Config { + public var branch: BranchConfig { + BranchConfig(dictionary: self[branchKey] as? [String: AnyObject] ?? [:]) + } +} diff --git a/Core/Core/Configuration/Config/Config.swift b/Core/Core/Configuration/Config/Config.swift index 5c1693c97..6aca3e6ca 100644 --- a/Core/Core/Configuration/Config/Config.swift +++ b/Core/Core/Configuration/Config/Config.swift @@ -26,6 +26,7 @@ public protocol ConfigProtocol { var uiComponents: UIComponentsConfig { get } var discovery: DiscoveryConfig { get } var braze: BrazeConfig { get } + var branch: BranchConfig { get } } public enum TokenType: String { @@ -65,7 +66,7 @@ public class Config { let dict = try? PropertyListSerialization.propertyList( from: data, options: [], - format: nil) as? [String: Any] + format: nil) as? [String: Any] else { return } properties = dict diff --git a/Core/CoreTests/Configuration/ConfigTests.swift b/Core/CoreTests/Configuration/ConfigTests.swift index e9b8a0ce7..21ab17ddf 100644 --- a/Core/CoreTests/Configuration/ConfigTests.swift +++ b/Core/CoreTests/Configuration/ConfigTests.swift @@ -56,6 +56,10 @@ class ConfigTests: XCTestCase { "BRAZE": [ "ENABLED": true, "PUSH_NOTIFICATIONS_ENABLED": true + ], + "BRANCH": [ + "ENABLED": true, + "KEY": "testBranchKey" ] ] @@ -125,4 +129,11 @@ class ConfigTests: XCTestCase { XCTAssertTrue(config.braze.pushNotificationsEnabled) } + + func testBranchConfigInitialization() { + let config = Config(properties: properties) + + XCTAssertTrue(config.branch.enabled) + XCTAssertEqual(config.branch.key, "testBranchKey") + } } diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index b02ea1814..c0c357d62 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -50,6 +50,10 @@ A50066912B61467B0024680B /* BrazeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50066902B61467B0024680B /* BrazeProvider.swift */; }; A50066932B614DCD0024680B /* FCMListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50066922B614DCD0024680B /* FCMListener.swift */; }; A50066952B614DEF0024680B /* BrazeListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50066942B614DEF0024680B /* BrazeListener.swift */; }; + A59568952B61630500ED4F90 /* DeepLinkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568942B61630500ED4F90 /* DeepLinkManager.swift */; }; + A59568972B61653700ED4F90 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568962B61653700ED4F90 /* DeepLink.swift */; }; + A59568992B616D9400ED4F90 /* PushLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568982B616D9400ED4F90 /* PushLink.swift */; }; + A59585AF2B62A07100A35A20 /* BranchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59585AE2B62A07100A35A20 /* BranchService.swift */; }; BA3042792B1F7147009B64B7 /* MSAL in Frameworks */ = {isa = PBXBuildFile; productRef = BA3042782B1F7147009B64B7 /* MSAL */; }; E0D6E6A32B1626B10089F9C9 /* Theme.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0D6E6A22B1626B10089F9C9 /* Theme.framework */; }; E0D6E6A42B1626D60089F9C9 /* Theme.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E0D6E6A22B1626B10089F9C9 /* Theme.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -125,6 +129,10 @@ A50066902B61467B0024680B /* BrazeProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrazeProvider.swift; sourceTree = ""; }; A50066922B614DCD0024680B /* FCMListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FCMListener.swift; sourceTree = ""; }; A50066942B614DEF0024680B /* BrazeListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrazeListener.swift; sourceTree = ""; }; + A59568942B61630500ED4F90 /* DeepLinkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkManager.swift; sourceTree = ""; }; + A59568962B61653700ED4F90 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; + A59568982B616D9400ED4F90 /* PushLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushLink.swift; sourceTree = ""; }; + A59585AE2B62A07100A35A20 /* BranchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BranchService.swift; sourceTree = ""; }; A89AD827F52CF6A6B903606E /* Pods-App-OpenEdX.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasestage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasestage.xcconfig"; sourceTree = ""; }; BB08ACD2CCA33D6DDDDD31B4 /* Pods-App-OpenEdX.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasedev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasedev.xcconfig"; sourceTree = ""; }; E0D6E6A22B1626B10089F9C9 /* Theme.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Theme.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -270,6 +278,7 @@ A50066882B613E800024680B /* Managers */ = { isa = PBXGroup; children = ( + A59568932B6162E400ED4F90 /* DeepLinkManager */, A50066872B613E4B0024680B /* PushNotificationsManager */, A50066892B613E990024680B /* AnalyticsManager */, ); @@ -303,6 +312,25 @@ path = Listeners; sourceTree = ""; }; + A59568932B6162E400ED4F90 /* DeepLinkManager */ = { + isa = PBXGroup; + children = ( + A59568942B61630500ED4F90 /* DeepLinkManager.swift */, + A59585AE2B62A07100A35A20 /* BranchService.swift */, + A59585AD2B62677B00A35A20 /* Link */, + ); + path = DeepLinkManager; + sourceTree = ""; + }; + A59585AD2B62677B00A35A20 /* Link */ = { + isa = PBXGroup; + children = ( + A59568962B61653700ED4F90 /* DeepLink.swift */, + A59568982B616D9400ED4F90 /* PushLink.swift */, + ); + path = Link; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -472,6 +500,7 @@ 0293A2052A6FCD430090A336 /* CoursePersistence.swift in Sources */, 020CA5D92AA0A25300970AAF /* AppStorage.swift in Sources */, 0298DF302A4EF7230023A257 /* AnalyticsManager.swift in Sources */, + A59585AF2B62A07100A35A20 /* BranchService.swift in Sources */, A50066912B61467B0024680B /* BrazeProvider.swift in Sources */, 0293A2072A6FCDA30090A336 /* DiscoveryPersistence.swift in Sources */, 07D5DA3528D075AA00752FD9 /* AppDelegate.swift in Sources */, @@ -487,8 +516,11 @@ 0770DE2028D0858A006D8A5D /* Router.swift in Sources */, 0293A2092A6FCDE50090A336 /* DashboardPersistence.swift in Sources */, 0770DE1728D080A1006D8A5D /* RouteController.swift in Sources */, + A59568952B61630500ED4F90 /* DeepLinkManager.swift in Sources */, A50066952B614DEF0024680B /* BrazeListener.swift in Sources */, 071009C928D1DB3F00344290 /* ScreenAssembly.swift in Sources */, + A59568972B61653700ED4F90 /* DeepLink.swift in Sources */, + A59568992B616D9400ED4F90 /* PushLink.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index 0a8a1c5b1..2bc95b2ed 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -48,6 +48,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { didFinishLaunchingWithOptions: launchOptions ) } + configureDeepLinkServices(launchOptions: launchOptions) } Theme.Fonts.registerFonts() @@ -62,6 +63,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { object: nil ) + if let pushManager = Container.shared.resolve(PushNotificationsManager.self) { + pushManager.performRegistration() + } + return true } @@ -70,6 +75,13 @@ class AppDelegate: UIResponder, UIApplicationDelegate { open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:] ) -> Bool { if let config = Container.shared.resolve(ConfigProtocol.self) { + if let deepLinkManager = Container.shared.resolve(DeepLinkManager.self), + deepLinkManager.serviceEnabled { + if deepLinkManager.handledURLWith(app: app, open: url, options: options) { + return true + } + } + if config.facebook.enabled { ApplicationDelegate.shared.application( app, @@ -125,16 +137,30 @@ class AppDelegate: UIResponder, UIApplicationDelegate { // Push Notifications func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { - let pushManager = Container.shared.resolve(PushNotificationsManager.self)! - pushManager.didRegisterForRemoteNotificationsWithDeviceToken(deviceToken: deviceToken) + if let pushManager = Container.shared.resolve(PushNotificationsManager.self) { + pushManager.didRegisterForRemoteNotificationsWithDeviceToken(deviceToken: deviceToken) + } } func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { - let pushManager = Container.shared.resolve(PushNotificationsManager.self)! - pushManager.didFailToRegisterForRemoteNotificationsWithError(error: error) + if let pushManager = Container.shared.resolve(PushNotificationsManager.self) { + pushManager.didFailToRegisterForRemoteNotificationsWithError(error: error) + } } - func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) { - let pushManager = Container.shared.resolve(PushNotificationsManager.self)! - pushManager.didReceiveRemoteNotification(userInfo: userInfo) + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + if let pushManager = Container.shared.resolve(PushNotificationsManager.self) { + pushManager.didReceiveRemoteNotification(userInfo: userInfo) + } completionHandler(.newData) } + + // Deep link + func configureDeepLinkServices(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { + if let deepLinkManager = Container.shared.resolve(DeepLinkManager.self) { + deepLinkManager.configureDeepLinkService(launchOptions: launchOptions) + } + } } diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 4e24e6924..74101e5a2 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -163,6 +163,12 @@ class AppAssembly: Assembly { config: r.resolve(ConfigProtocol.self)! ) }.inObjectScope(.container) + + container.register(DeepLinkManager.self) { r in + DeepLinkManager( + config: r.resolve(ConfigProtocol.self)! + ) + }.inObjectScope(.container) } } // swiftlint:enable function_body_length diff --git a/OpenEdX/Managers/DeepLinkManager/BranchService.swift b/OpenEdX/Managers/DeepLinkManager/BranchService.swift new file mode 100644 index 000000000..4d36fb27d --- /dev/null +++ b/OpenEdX/Managers/DeepLinkManager/BranchService.swift @@ -0,0 +1,35 @@ +// +// BranchService.swift +// OpenEdX +// +// Created by Anton Yarmolenka on 25/01/2024. +// + +import Foundation +import UIKit + +class BranchService: DeepLinkService { + // configure service + func configureWith(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { + + } + + // handle url + func handledURLWith( + app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] + ) -> Bool { + false + } + + // This method process push notification with the link object + func processNotification(with link: PushLink) { + + } + + // This method process the deep link with response parameters + func processDeepLink(with params: [String: Any]) { + + } +} diff --git a/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift b/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift new file mode 100644 index 000000000..ac945cec4 --- /dev/null +++ b/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift @@ -0,0 +1,72 @@ +// +// DeepLinkManager.swift +// OpenEdX +// +// Created by Anton Yarmolenka on 24/01/2024. +// + +import Foundation +import Core +import UIKit + +public protocol DeepLinkService { + func processNotification(with link: PushLink) + func processDeepLink(with params: [String: Any]) + func configureWith(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) + func handledURLWith(app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any]) -> Bool +} + +class DeepLinkManager { + private var service: DeepLinkService? + + // Init manager + public init(config: ConfigProtocol) { + self.service = self.serviceFor(config: config) + } + + private func serviceFor(config: ConfigProtocol) -> DeepLinkService? { + if config.branch.enabled { + return BranchService() + } + return nil + } + + // check if service is added (means enabled) + var serviceEnabled: Bool { + service != nil + } + + // Configure services + func configureDeepLinkService(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { + if let service = service { + service.configureWith(launchOptions: launchOptions) + } + } + + // Handle open url + func handledURLWith( + app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + if let service = service { + return service.handledURLWith(app: app, open: url, options: options) + } + return false + } + + // This method process push notification with the link object + func processNotification(with link: PushLink) { + if let service = service { + service.processNotification(with: link) + } + } + + // This method process the deep link with response parameters + func processDeepLink(with params: [String: Any]) { + if let service = service { + service.processDeepLink(with: params) + } + } + +} diff --git a/OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift b/OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift new file mode 100644 index 000000000..3df8a21e6 --- /dev/null +++ b/OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift @@ -0,0 +1,18 @@ +// +// DeepLink.swift +// OpenEdX +// +// Created by Anton Yarmolenka on 24/01/2024. +// + +import Foundation + +enum DeepLinkType: String { + case none +} + +public class DeepLink { + init(dictionary: [String: Any]) { + + } +} diff --git a/OpenEdX/Managers/DeepLinkManager/Link/PushLink.swift b/OpenEdX/Managers/DeepLinkManager/Link/PushLink.swift new file mode 100644 index 000000000..262bda304 --- /dev/null +++ b/OpenEdX/Managers/DeepLinkManager/Link/PushLink.swift @@ -0,0 +1,30 @@ +// +// PushLink.swift +// OpenEdX +// +// Created by Anton Yarmolenka on 24/01/2024. +// + +import Foundation + +enum DataKeys: String { + case title + case body + case aps + case alert +} + +// This link will have information of course and screen type which will be use to route on particular screen. +public class PushLink: DeepLink { + let title: String? + let body: String? + + override init(dictionary: [String: Any]) { + let aps = dictionary[DataKeys.aps.rawValue] as? [String: Any] + let alert = aps?[DataKeys.alert.rawValue] as? [String: Any] + title = alert?[DataKeys.title.rawValue] as? String + body = alert?[DataKeys.body.rawValue] as? String + + super.init(dictionary: dictionary) + } +} diff --git a/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift b/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift index a705953e0..7da4ddc4d 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift @@ -8,7 +8,5 @@ import Foundation class BrazeListener: PushNotificationsListener { - func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) { - - } + func notificationToThisListener(userinfo: [AnyHashable: Any]) -> Bool { false } } diff --git a/OpenEdX/Managers/PushNotificationsManager/Listeners/FCMListener.swift b/OpenEdX/Managers/PushNotificationsManager/Listeners/FCMListener.swift index 3c0725e04..ce965be51 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Listeners/FCMListener.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Listeners/FCMListener.swift @@ -8,7 +8,5 @@ import Foundation class FCMListener: PushNotificationsListener { - func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) { - - } + func notificationToThisListener(userinfo: [AnyHashable: Any]) -> Bool { false } } diff --git a/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift b/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift index 23e747ccf..4f6338e14 100644 --- a/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift +++ b/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift @@ -7,6 +7,8 @@ import Foundation import Core +import UIKit +import Swinject public protocol PushNotificationsProvider { func didRegisterForRemoteNotificationsWithDeviceToken(deviceToken: Data) @@ -14,9 +16,20 @@ public protocol PushNotificationsProvider { } protocol PushNotificationsListener { + func notificationToThisListener(userinfo: [AnyHashable: Any]) -> Bool func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) } +extension PushNotificationsListener { + func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) { + guard let dictionary = userInfo as? [String: Any], notificationToThisListener(userinfo: userInfo) else { return } + let link = PushLink(dictionary: dictionary) + if let deepLinkManager = Container.shared.resolve(DeepLinkManager.self) { + deepLinkManager.processNotification(with: link) + } + } +} + class PushNotificationsManager { private var providers: [PushNotificationsProvider] = [] private var listeners: [PushNotificationsListener] = [] @@ -49,6 +62,21 @@ class PushNotificationsManager { return rListeners } + // Register for push notifications + public func performRegistration() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { (granted, error) in + if granted { + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + } else if let error = error { + debugLog("Push notifications permission error: \(error.localizedDescription)") + } else { + debugLog("Permission for push notifications denied.") + } + } + } + // Proccess functions from app delegate public func didRegisterForRemoteNotificationsWithDeviceToken(deviceToken: Data) { for provider in providers { From 3f4049c2a02a507ada69279c2ee6ede74b58dbec Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Mon, 29 Jan 2024 15:47:24 +0100 Subject: [PATCH 003/136] chore: test braze experiment chore: test braze --- OpenEdX.xcodeproj/project.pbxproj | 33 +++++++++++++++++++ OpenEdX/AppDelegate.swift | 3 ++ .../DeepLinkManager/DeepLinkManager.swift | 2 +- .../Listeners/BrazeListener.swift | 7 +++- .../Providers/BrazeProvider.swift | 11 +++++-- Podfile.lock | 2 +- default_config/config_settings.yaml | 2 +- 7 files changed, 54 insertions(+), 6 deletions(-) diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index c0c357d62..2d122ada1 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -54,6 +54,9 @@ A59568972B61653700ED4F90 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568962B61653700ED4F90 /* DeepLink.swift */; }; A59568992B616D9400ED4F90 /* PushLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568982B616D9400ED4F90 /* PushLink.swift */; }; A59585AF2B62A07100A35A20 /* BranchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59585AE2B62A07100A35A20 /* BranchService.swift */; }; + A5BECBD02B67C161001776CD /* BrazeKit in Frameworks */ = {isa = PBXBuildFile; productRef = A5BECBCF2B67C161001776CD /* BrazeKit */; }; + A5BECBD22B67C161001776CD /* BrazeKitCompat in Frameworks */ = {isa = PBXBuildFile; productRef = A5BECBD12B67C161001776CD /* BrazeKitCompat */; }; + A5BECBD42B67C161001776CD /* BrazeLocation in Frameworks */ = {isa = PBXBuildFile; productRef = A5BECBD32B67C161001776CD /* BrazeLocation */; }; BA3042792B1F7147009B64B7 /* MSAL in Frameworks */ = {isa = PBXBuildFile; productRef = BA3042782B1F7147009B64B7 /* MSAL */; }; E0D6E6A32B1626B10089F9C9 /* Theme.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0D6E6A22B1626B10089F9C9 /* Theme.framework */; }; E0D6E6A42B1626D60089F9C9 /* Theme.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E0D6E6A22B1626B10089F9C9 /* Theme.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -152,7 +155,10 @@ BA3042792B1F7147009B64B7 /* MSAL in Frameworks */, 072787B128D34D83002E9142 /* Discovery.framework in Frameworks */, 0218196428F734FA00202564 /* Discussion.framework in Frameworks */, + A5BECBD02B67C161001776CD /* BrazeKit in Frameworks */, 0219C67728F4347600D64452 /* Course.framework in Frameworks */, + A5BECBD42B67C161001776CD /* BrazeLocation in Frameworks */, + A5BECBD22B67C161001776CD /* BrazeKitCompat in Frameworks */, 027DB33028D8A063002B6862 /* Dashboard.framework in Frameworks */, 95C140F3BDF778364986E83B /* Pods_App_OpenEdX.framework in Frameworks */, ); @@ -354,6 +360,9 @@ name = OpenEdX; packageProductDependencies = ( BA3042782B1F7147009B64B7 /* MSAL */, + A5BECBCF2B67C161001776CD /* BrazeKit */, + A5BECBD12B67C161001776CD /* BrazeKitCompat */, + A5BECBD32B67C161001776CD /* BrazeLocation */, ); productName = OpenEdX; productReference = 07D5DA3128D075AA00752FD9 /* OpenEdX.app */; @@ -386,6 +395,7 @@ mainGroup = 07D5DA2828D075AA00752FD9; packageReferences = ( BA3042772B1F7147009B64B7 /* XCRemoteSwiftPackageReference "microsoft-authentication-library-for-objc" */, + A5BECBCE2B67C161001776CD /* XCRemoteSwiftPackageReference "braze-swift-sdk" */, ); productRefGroup = 07D5DA3228D075AA00752FD9 /* Products */; projectDirPath = ""; @@ -1143,6 +1153,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + A5BECBCE2B67C161001776CD /* XCRemoteSwiftPackageReference "braze-swift-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/braze-inc/braze-swift-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 7.5.0; + }; + }; BA3042772B1F7147009B64B7 /* XCRemoteSwiftPackageReference "microsoft-authentication-library-for-objc" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/AzureAD/microsoft-authentication-library-for-objc"; @@ -1154,6 +1172,21 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + A5BECBCF2B67C161001776CD /* BrazeKit */ = { + isa = XCSwiftPackageProductDependency; + package = A5BECBCE2B67C161001776CD /* XCRemoteSwiftPackageReference "braze-swift-sdk" */; + productName = BrazeKit; + }; + A5BECBD12B67C161001776CD /* BrazeKitCompat */ = { + isa = XCSwiftPackageProductDependency; + package = A5BECBCE2B67C161001776CD /* XCRemoteSwiftPackageReference "braze-swift-sdk" */; + productName = BrazeKitCompat; + }; + A5BECBD32B67C161001776CD /* BrazeLocation */ = { + isa = XCSwiftPackageProductDependency; + package = A5BECBCE2B67C161001776CD /* XCRemoteSwiftPackageReference "braze-swift-sdk" */; + productName = BrazeLocation; + }; BA3042782B1F7147009B64B7 /* MSAL */ = { isa = XCSwiftPackageProductDependency; package = BA3042772B1F7147009B64B7 /* XCRemoteSwiftPackageReference "microsoft-authentication-library-for-objc" */; diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index 2bc95b2ed..567dd5899 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -16,6 +16,7 @@ import GoogleSignIn import FacebookCore import MSAL import Theme +import BrazeKit @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { @@ -24,6 +25,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { UIApplication.shared.delegate as! AppDelegate } + var braze: Braze? + var window: UIWindow? private var assembler: Assembler? diff --git a/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift b/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift index ac945cec4..20bd8f18f 100644 --- a/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift +++ b/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift @@ -46,7 +46,7 @@ class DeepLinkManager { // Handle open url func handledURLWith( app: UIApplication, - open url: URL, + open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:] ) -> Bool { if let service = service { diff --git a/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift b/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift index 7da4ddc4d..f9b34fa60 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift @@ -8,5 +8,10 @@ import Foundation class BrazeListener: PushNotificationsListener { - func notificationToThisListener(userinfo: [AnyHashable: Any]) -> Bool { false } + func notificationToThisListener(userinfo: [AnyHashable: Any]) -> Bool { + //A push notification sent from the braze has a key ab in it like ab = {c = "c_value";}; + guard let _ = userinfo["ab"] as? [String : Any], userinfo.count > 0 + else { return false } + return true + } } diff --git a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift index 043e7d62b..01d4d9f99 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift @@ -6,12 +6,19 @@ // import Foundation +import BrazeKit +import UIKit class BrazeProvider: PushNotificationsProvider { func didRegisterForRemoteNotificationsWithDeviceToken(deviceToken: Data) { - + let configuration = Braze.Configuration( + apiKey: "", + endpoint: "" + ) + let braze = Braze(configuration: configuration) + braze.notifications.register(deviceToken: deviceToken) + (UIApplication.shared.delegate as? AppDelegate)?.braze = braze } func didFailToRegisterForRemoteNotificationsWithError(error: Error) { - } } diff --git a/Podfile.lock b/Podfile.lock index d9169ef4b..fadc65f3b 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -182,4 +182,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 544edab2f9ecc4ac18973fb8865f1d0613ec8a28 -COCOAPODS: 1.13.0 +COCOAPODS: 1.14.2 diff --git a/default_config/config_settings.yaml b/default_config/config_settings.yaml index 249e93fc3..c63b34959 100644 --- a/default_config/config_settings.yaml +++ b/default_config/config_settings.yaml @@ -1,4 +1,4 @@ -config_directory: './default_config' +config_directory: './../edx-mobile-config' config_mapping: prod: 'prod' stage: 'stage' From 8bc62e79541479953f5a4083e3a6ba1b627514cb Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Fri, 2 Feb 2024 15:45:56 +0100 Subject: [PATCH 004/136] chore: added segment package --- .../Presentation/AuthorizationAnalytics.swift | 4 +- .../Presentation/Login/SignInViewModel.swift | 4 +- .../Registration/SignUpViewModel.swift | 4 +- Core/Core.xcodeproj/project.pbxproj | 4 + Core/Core/Configuration/Config/Config.swift | 1 + .../Configuration/Config/SegmentConfig.swift | 31 ++++++++ OpenEdX.xcodeproj/project.pbxproj | 70 ++++++++++++------ OpenEdX/AppDelegate.swift | 18 ++++- .../AnalyticsManager/AnalyticsManager.swift | 16 +++- .../Providers/BrazeProvider.swift | 18 +++-- OpenEdX/RouteController.swift | 2 +- Podfile | 2 +- Podfile.lock | 73 +++---------------- 13 files changed, 139 insertions(+), 108 deletions(-) create mode 100644 Core/Core/Configuration/Config/SegmentConfig.swift diff --git a/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift b/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift index d73799435..060560ba6 100644 --- a/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift +++ b/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift @@ -30,7 +30,7 @@ public enum SocialAuthMethod: String { //sourcery: AutoMockable public protocol AuthorizationAnalytics { - func setUserID(_ id: String) + func identify(id: String, username: String, email: String) func userLogin(method: AuthMethod) func signUpClicked() func createAccountClicked() @@ -41,7 +41,7 @@ public protocol AuthorizationAnalytics { #if DEBUG class AuthorizationAnalyticsMock: AuthorizationAnalytics { - public func setUserID(_ id: String) {} + func identify(id: String, username: String, email: String) {} public func userLogin(method: AuthMethod) {} public func signUpClicked() {} public func createAccountClicked() {} diff --git a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift index 3edd28bcf..deebec363 100644 --- a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift +++ b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift @@ -79,7 +79,7 @@ public class SignInViewModel: ObservableObject { isShowProgress = true do { let user = try await interactor.login(username: username, password: password) - analytics.setUserID("\(user.id)") + analytics.identify(id: "\(user.id)", username: user.username, email: user.email) analytics.userLogin(method: .password) router.showMainOrWhatsNewScreen(sourceScreen: sourceScreen) } catch let error { @@ -110,7 +110,7 @@ public class SignInViewModel: ObservableObject { isShowProgress = true do { let user = try await interactor.login(externalToken: externalToken, backend: backend) - analytics.setUserID("\(user.id)") + analytics.identify(id: "\(user.id)", username: user.username, email: user.email) analytics.userLogin(method: authMethod) router.showMainOrWhatsNewScreen(sourceScreen: sourceScreen) } catch let error { diff --git a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift index 760f836dd..ff5d30452 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift @@ -115,7 +115,7 @@ public class SignUpViewModel: ObservableObject { fields: validateFields, isSocial: externalToken != nil ) - analytics.setUserID("\(user.id)") + analytics.identify(id: "\(user.id)", username: user.username, email: user.email) analytics.registrationSuccess() isShowProgress = false router.showMainOrWhatsNewScreen(sourceScreen: sourceScreen) @@ -172,7 +172,7 @@ public class SignUpViewModel: ObservableObject { do { isShowProgress = true let user = try await interactor.login(externalToken: response.token, backend: backend) - analytics.setUserID("\(user.id)") + analytics.identify(id: "\(user.id)", username: user.username, email: user.email) analytics.userLogin(method: authMethod) isShowProgress = false router.showMainOrWhatsNewScreen(sourceScreen: sourceScreen) diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 4382f8035..1149e0489 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -124,6 +124,7 @@ 0770DE6128D0B2CB006D8A5D /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE6028D0B2CB006D8A5D /* Assets.swift */; }; 07DDFCBD29A780BB00572595 /* UINavigationController+Animation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07DDFCBC29A780BB00572595 /* UINavigationController+Animation.swift */; }; 07E0939F2B308D2800F1E4B2 /* Data_Certificate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E0939E2B308D2800F1E4B2 /* Data_Certificate.swift */; }; + A51CDBE72B6D21F2009B6D4E /* SegmentConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51CDBE62B6D21F2009B6D4E /* SegmentConfig.swift */; }; A53A32352B233DEC005FE38A /* ThemeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A32342B233DEC005FE38A /* ThemeConfig.swift */; }; A595689B2B6173DF00ED4F90 /* BranchConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A595689A2B6173DF00ED4F90 /* BranchConfig.swift */; }; A5F4E7B52B61544A00ACD166 /* BrazeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */; }; @@ -295,6 +296,7 @@ 3B74C6685E416657F3C5F5A8 /* Pods-App-Core.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.releaseprod.xcconfig"; sourceTree = ""; }; 60153262DBC2F9E660D7E11B /* Pods-App-Core.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.release.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.release.xcconfig"; sourceTree = ""; }; 9D5B06CAA99EA5CD49CBE2BB /* Pods-App-Core.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debugdev.xcconfig"; sourceTree = ""; }; + A51CDBE62B6D21F2009B6D4E /* SegmentConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentConfig.swift; sourceTree = ""; }; A53A32342B233DEC005FE38A /* ThemeConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThemeConfig.swift; sourceTree = ""; }; A595689A2B6173DF00ED4F90 /* BranchConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BranchConfig.swift; sourceTree = ""; }; A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrazeConfig.swift; sourceTree = ""; }; @@ -755,6 +757,7 @@ DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */, A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */, A595689A2B6173DF00ED4F90 /* BranchConfig.swift */, + A51CDBE62B6D21F2009B6D4E /* SegmentConfig.swift */, DBF6F2492B0380E00098414B /* FeaturesConfig.swift */, DBF6F2452B01DAFE0098414B /* AgreementConfig.swift */, BAFB99812B0E2354007D09F9 /* FacebookConfig.swift */, @@ -995,6 +998,7 @@ 0236961F28F9A2F600EEF206 /* AuthEndpoint.swift in Sources */, 02B3E3B32930198600A50475 /* AVPlayerViewControllerExtension.swift in Sources */, 0295C885299B99DD00ABE571 /* RefreshableScrollView.swift in Sources */, + A51CDBE72B6D21F2009B6D4E /* SegmentConfig.swift in Sources */, 0282DA7328F98CC9003C3F07 /* WebUnitView.swift in Sources */, E0D586362B314CD3009B4BA7 /* LogistrationBottomView.swift in Sources */, 0727878128D25EFD002E9142 /* SnackBarView.swift in Sources */, diff --git a/Core/Core/Configuration/Config/Config.swift b/Core/Core/Configuration/Config/Config.swift index 6aca3e6ca..ae60e26eb 100644 --- a/Core/Core/Configuration/Config/Config.swift +++ b/Core/Core/Configuration/Config/Config.swift @@ -27,6 +27,7 @@ public protocol ConfigProtocol { var discovery: DiscoveryConfig { get } var braze: BrazeConfig { get } var branch: BranchConfig { get } + var segment: SegmentConfig { get } } public enum TokenType: String { diff --git a/Core/Core/Configuration/Config/SegmentConfig.swift b/Core/Core/Configuration/Config/SegmentConfig.swift new file mode 100644 index 000000000..37ed53cc9 --- /dev/null +++ b/Core/Core/Configuration/Config/SegmentConfig.swift @@ -0,0 +1,31 @@ +// +// SegmentConfig.swift +// Core +// +// Created by Anton Yarmolenka on 02/02/2024. +// + +import Foundation + +private enum SegmentKeys: String, RawStringExtractable { + case enabled = "ENABLED" + case writeKey = "SEGMENT_IO_WRITE_KEY" +} + +public final class SegmentConfig: NSObject { + public var enabled: Bool = false + public var writeKey: String = "" + + init(dictionary: [String: AnyObject]) { + super.init() + enabled = dictionary[SegmentKeys.enabled] as? Bool == true + writeKey = dictionary[SegmentKeys.writeKey] as? String ?? "" + } +} + +private let segmentKey = "SEGMENT_IO" +extension Config { + public var segment: SegmentConfig { + SegmentConfig(dictionary: self[segmentKey] as? [String: AnyObject] ?? [:]) + } +} diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 2d122ada1..13265bdee 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -50,13 +50,14 @@ A50066912B61467B0024680B /* BrazeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50066902B61467B0024680B /* BrazeProvider.swift */; }; A50066932B614DCD0024680B /* FCMListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50066922B614DCD0024680B /* FCMListener.swift */; }; A50066952B614DEF0024680B /* BrazeListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50066942B614DEF0024680B /* BrazeListener.swift */; }; + A51CDBE52B6D1E93009B6D4E /* Segment in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBE42B6D1E93009B6D4E /* Segment */; }; + A51CDBEA2B6D25C9009B6D4E /* SegmentFirebase in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBE92B6D25C9009B6D4E /* SegmentFirebase */; }; + A51CDBED2B6D2BEE009B6D4E /* SegmentBraze in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBEC2B6D2BEE009B6D4E /* SegmentBraze */; }; + A51CDBEF2B6D2BEE009B6D4E /* SegmentBrazeUI in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBEE2B6D2BEE009B6D4E /* SegmentBrazeUI */; }; A59568952B61630500ED4F90 /* DeepLinkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568942B61630500ED4F90 /* DeepLinkManager.swift */; }; A59568972B61653700ED4F90 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568962B61653700ED4F90 /* DeepLink.swift */; }; A59568992B616D9400ED4F90 /* PushLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568982B616D9400ED4F90 /* PushLink.swift */; }; A59585AF2B62A07100A35A20 /* BranchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59585AE2B62A07100A35A20 /* BranchService.swift */; }; - A5BECBD02B67C161001776CD /* BrazeKit in Frameworks */ = {isa = PBXBuildFile; productRef = A5BECBCF2B67C161001776CD /* BrazeKit */; }; - A5BECBD22B67C161001776CD /* BrazeKitCompat in Frameworks */ = {isa = PBXBuildFile; productRef = A5BECBD12B67C161001776CD /* BrazeKitCompat */; }; - A5BECBD42B67C161001776CD /* BrazeLocation in Frameworks */ = {isa = PBXBuildFile; productRef = A5BECBD32B67C161001776CD /* BrazeLocation */; }; BA3042792B1F7147009B64B7 /* MSAL in Frameworks */ = {isa = PBXBuildFile; productRef = BA3042782B1F7147009B64B7 /* MSAL */; }; E0D6E6A32B1626B10089F9C9 /* Theme.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0D6E6A22B1626B10089F9C9 /* Theme.framework */; }; E0D6E6A42B1626D60089F9C9 /* Theme.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E0D6E6A22B1626B10089F9C9 /* Theme.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -153,14 +154,15 @@ 025DE1A428DB4DAE0053E0F4 /* Profile.framework in Frameworks */, 0770DE4B28D0A462006D8A5D /* Authorization.framework in Frameworks */, BA3042792B1F7147009B64B7 /* MSAL in Frameworks */, + A51CDBE52B6D1E93009B6D4E /* Segment in Frameworks */, 072787B128D34D83002E9142 /* Discovery.framework in Frameworks */, 0218196428F734FA00202564 /* Discussion.framework in Frameworks */, - A5BECBD02B67C161001776CD /* BrazeKit in Frameworks */, + A51CDBEA2B6D25C9009B6D4E /* SegmentFirebase in Frameworks */, 0219C67728F4347600D64452 /* Course.framework in Frameworks */, - A5BECBD42B67C161001776CD /* BrazeLocation in Frameworks */, - A5BECBD22B67C161001776CD /* BrazeKitCompat in Frameworks */, 027DB33028D8A063002B6862 /* Dashboard.framework in Frameworks */, 95C140F3BDF778364986E83B /* Pods_App_OpenEdX.framework in Frameworks */, + A51CDBED2B6D2BEE009B6D4E /* SegmentBraze in Frameworks */, + A51CDBEF2B6D2BEE009B6D4E /* SegmentBrazeUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -360,9 +362,10 @@ name = OpenEdX; packageProductDependencies = ( BA3042782B1F7147009B64B7 /* MSAL */, - A5BECBCF2B67C161001776CD /* BrazeKit */, - A5BECBD12B67C161001776CD /* BrazeKitCompat */, - A5BECBD32B67C161001776CD /* BrazeLocation */, + A51CDBE42B6D1E93009B6D4E /* Segment */, + A51CDBE92B6D25C9009B6D4E /* SegmentFirebase */, + A51CDBEC2B6D2BEE009B6D4E /* SegmentBraze */, + A51CDBEE2B6D2BEE009B6D4E /* SegmentBrazeUI */, ); productName = OpenEdX; productReference = 07D5DA3128D075AA00752FD9 /* OpenEdX.app */; @@ -395,7 +398,9 @@ mainGroup = 07D5DA2828D075AA00752FD9; packageReferences = ( BA3042772B1F7147009B64B7 /* XCRemoteSwiftPackageReference "microsoft-authentication-library-for-objc" */, - A5BECBCE2B67C161001776CD /* XCRemoteSwiftPackageReference "braze-swift-sdk" */, + A51CDBE32B6D1E93009B6D4E /* XCRemoteSwiftPackageReference "analytics-swift" */, + A51CDBE82B6D25C9009B6D4E /* XCRemoteSwiftPackageReference "analytics-swift-firebase" */, + A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */, ); productRefGroup = 07D5DA3228D075AA00752FD9 /* Products */; projectDirPath = ""; @@ -1153,12 +1158,28 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - A5BECBCE2B67C161001776CD /* XCRemoteSwiftPackageReference "braze-swift-sdk" */ = { + A51CDBE32B6D1E93009B6D4E /* XCRemoteSwiftPackageReference "analytics-swift" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/braze-inc/braze-swift-sdk"; + repositoryURL = "git@github.com:segmentio/analytics-swift.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 7.5.0; + minimumVersion = 1.5.2; + }; + }; + A51CDBE82B6D25C9009B6D4E /* XCRemoteSwiftPackageReference "analytics-swift-firebase" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/segment-integrations/analytics-swift-firebase"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.3.4; + }; + }; + A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/braze-inc/braze-segment-swift"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.2.0; }; }; BA3042772B1F7147009B64B7 /* XCRemoteSwiftPackageReference "microsoft-authentication-library-for-objc" */ = { @@ -1172,20 +1193,25 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - A5BECBCF2B67C161001776CD /* BrazeKit */ = { + A51CDBE42B6D1E93009B6D4E /* Segment */ = { + isa = XCSwiftPackageProductDependency; + package = A51CDBE32B6D1E93009B6D4E /* XCRemoteSwiftPackageReference "analytics-swift" */; + productName = Segment; + }; + A51CDBE92B6D25C9009B6D4E /* SegmentFirebase */ = { isa = XCSwiftPackageProductDependency; - package = A5BECBCE2B67C161001776CD /* XCRemoteSwiftPackageReference "braze-swift-sdk" */; - productName = BrazeKit; + package = A51CDBE82B6D25C9009B6D4E /* XCRemoteSwiftPackageReference "analytics-swift-firebase" */; + productName = SegmentFirebase; }; - A5BECBD12B67C161001776CD /* BrazeKitCompat */ = { + A51CDBEC2B6D2BEE009B6D4E /* SegmentBraze */ = { isa = XCSwiftPackageProductDependency; - package = A5BECBCE2B67C161001776CD /* XCRemoteSwiftPackageReference "braze-swift-sdk" */; - productName = BrazeKitCompat; + package = A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */; + productName = SegmentBraze; }; - A5BECBD32B67C161001776CD /* BrazeLocation */ = { + A51CDBEE2B6D2BEE009B6D4E /* SegmentBrazeUI */ = { isa = XCSwiftPackageProductDependency; - package = A5BECBCE2B67C161001776CD /* XCRemoteSwiftPackageReference "braze-swift-sdk" */; - productName = BrazeLocation; + package = A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */; + productName = SegmentBrazeUI; }; BA3042782B1F7147009B64B7 /* MSAL */ = { isa = XCSwiftPackageProductDependency; diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index 567dd5899..c101a7c3d 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -9,14 +9,16 @@ import UIKit import Core import Swinject import FirebaseCore -import FirebaseAnalytics +//import FirebaseAnalytics import FirebaseCrashlytics import Profile import GoogleSignIn import FacebookCore import MSAL import Theme -import BrazeKit +//import BrazeKit +import Segment +import SegmentFirebase @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { @@ -25,7 +27,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { UIApplication.shared.delegate as! AppDelegate } - var braze: Braze? +// var braze: Braze? + var analytics: Analytics? var window: UIWindow? @@ -52,6 +55,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ) } configureDeepLinkServices(launchOptions: launchOptions) + if config.segment.enabled { + let configuration = Configuration(writeKey: config.segment.writeKey) + .trackApplicationLifecycleEvents(true) + .flushInterval(10) + analytics = Analytics(configuration: configuration) + if config.firebase.isAnalyticsSourceSegment { + analytics?.add(plugin: FirebaseDestination()) + } + } } Theme.Fonts.registerFonts() diff --git a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift index ecb74b036..8d106c2af 100644 --- a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift +++ b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift @@ -7,13 +7,14 @@ import Foundation import Core -import FirebaseAnalytics +//import FirebaseAnalytics import Authorization import Discovery import Dashboard import Profile import Course import Discussion +import UIKit class AnalyticsManager: AuthorizationAnalytics, MainScreenAnalytics, @@ -23,8 +24,12 @@ class AnalyticsManager: AuthorizationAnalytics, CourseAnalytics, DiscussionAnalytics { - public func setUserID(_ id: String) { - Analytics.setUserID(id) + public func identify(id: String, username: String, email: String) { + let traits : [String: String] = [ + "email": email, + "username": username + ] + (UIApplication.shared.delegate as? AppDelegate)?.analytics?.identify(userId: id, traits: traits) } public func userLogin(method: AuthMethod) { @@ -309,7 +314,10 @@ class AnalyticsManager: AuthorizationAnalytics, } private func logEvent(_ event: Event, parameters: [String: Any]? = nil) { - Analytics.logEvent(event.rawValue, parameters: parameters) + (UIApplication.shared.delegate as? AppDelegate)?.analytics?.track( + name: event.rawValue, + properties: parameters + ) } } diff --git a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift index 01d4d9f99..5d6121f20 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift @@ -6,18 +6,22 @@ // import Foundation -import BrazeKit +//import BrazeKit import UIKit +import Segment +import SegmentBrazeUI class BrazeProvider: PushNotificationsProvider { func didRegisterForRemoteNotificationsWithDeviceToken(deviceToken: Data) { - let configuration = Braze.Configuration( - apiKey: "", - endpoint: "" + (UIApplication.shared.delegate as? AppDelegate)?.analytics?.add( + plugin: BrazeDestination( + additionalConfiguration: { configuration in + configuration.logger.level = .debug + }, additionalSetup: { braze in + braze.notifications.register(deviceToken: deviceToken) + } + ) ) - let braze = Braze(configuration: configuration) - braze.notifications.register(deviceToken: deviceToken) - (UIApplication.shared.delegate as? AppDelegate)?.braze = braze } func didFailToRegisterForRemoteNotificationsWithError(error: Error) { } diff --git a/OpenEdX/RouteController.swift b/OpenEdX/RouteController.swift index 70bae693c..499992615 100644 --- a/OpenEdX/RouteController.swift +++ b/OpenEdX/RouteController.swift @@ -30,7 +30,7 @@ class RouteController: UIViewController { super.viewDidLoad() if let user = appStorage.user, appStorage.accessToken != nil { - analytics.setUserID("\(user.id)") + analytics.identify(id: "\(user.id)", username: user.username ?? "", email: user.email ?? "") DispatchQueue.main.async { self.showMainOrWhatsNewScreen() } diff --git a/Podfile b/Podfile index 291bcb28e..c14e319bd 100644 --- a/Podfile +++ b/Podfile @@ -17,7 +17,7 @@ abstract_target "App" do project './Core/Core.xcodeproj' workspace './Core/Core.xcodeproj' #Firebase - pod 'FirebaseAnalytics', '~> 10.11' +# pod 'FirebaseAnalytics', '~> 10.11' pod 'FirebaseCrashlytics', '~> 10.11' #Networking pod 'Alamofire', '~> 5.7' diff --git a/Podfile.lock b/Podfile.lock index fadc65f3b..706da0909 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,23 +1,5 @@ PODS: - Alamofire (5.8.0) - - FirebaseAnalytics (10.15.0): - - FirebaseAnalytics/AdIdSupport (= 10.15.0) - - FirebaseCore (~> 10.0) - - FirebaseInstallations (~> 10.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.11) - - GoogleUtilities/MethodSwizzler (~> 7.11) - - GoogleUtilities/Network (~> 7.11) - - "GoogleUtilities/NSData+zlib (~> 7.11)" - - nanopb (< 2.30910.0, >= 2.30908.0) - - FirebaseAnalytics/AdIdSupport (10.15.0): - - FirebaseCore (~> 10.0) - - FirebaseInstallations (~> 10.0) - - GoogleAppMeasurement (= 10.15.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.11) - - GoogleUtilities/MethodSwizzler (~> 7.11) - - GoogleUtilities/Network (~> 7.11) - - "GoogleUtilities/NSData+zlib (~> 7.11)" - - nanopb (< 2.30910.0, >= 2.30908.0) - FirebaseCore (10.15.0): - FirebaseCoreInternal (~> 10.0) - GoogleUtilities/Environment (~> 7.8) @@ -34,7 +16,7 @@ PODS: - GoogleUtilities/Environment (~> 7.8) - nanopb (< 2.30910.0, >= 2.30908.0) - PromisesObjC (~> 2.1) - - FirebaseInstallations (10.15.0): + - FirebaseInstallations (10.17.0): - FirebaseCore (~> 10.0) - GoogleUtilities/Environment (~> 7.8) - GoogleUtilities/UserDefaults (~> 7.8) @@ -47,56 +29,24 @@ PODS: - GoogleUtilities/Environment (~> 7.10) - nanopb (< 2.30910.0, >= 2.30908.0) - PromisesSwift (~> 2.1) - - GoogleAppMeasurement (10.15.0): - - GoogleAppMeasurement/AdIdSupport (= 10.15.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.11) - - GoogleUtilities/MethodSwizzler (~> 7.11) - - GoogleUtilities/Network (~> 7.11) - - "GoogleUtilities/NSData+zlib (~> 7.11)" - - nanopb (< 2.30910.0, >= 2.30908.0) - - GoogleAppMeasurement/AdIdSupport (10.15.0): - - GoogleAppMeasurement/WithoutAdIdSupport (= 10.15.0) - - GoogleUtilities/AppDelegateSwizzler (~> 7.11) - - GoogleUtilities/MethodSwizzler (~> 7.11) - - GoogleUtilities/Network (~> 7.11) - - "GoogleUtilities/NSData+zlib (~> 7.11)" - - nanopb (< 2.30910.0, >= 2.30908.0) - - GoogleAppMeasurement/WithoutAdIdSupport (10.15.0): - - GoogleUtilities/AppDelegateSwizzler (~> 7.11) - - GoogleUtilities/MethodSwizzler (~> 7.11) - - GoogleUtilities/Network (~> 7.11) - - "GoogleUtilities/NSData+zlib (~> 7.11)" - - nanopb (< 2.30910.0, >= 2.30908.0) - GoogleDataTransport (9.2.5): - GoogleUtilities/Environment (~> 7.7) - nanopb (< 2.30910.0, >= 2.30908.0) - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/AppDelegateSwizzler (7.11.5): - - GoogleUtilities/Environment - - GoogleUtilities/Logger - - GoogleUtilities/Network - GoogleUtilities/Environment (7.11.5): - PromisesObjC (< 3.0, >= 1.2) - GoogleUtilities/Logger (7.11.5): - GoogleUtilities/Environment - - GoogleUtilities/MethodSwizzler (7.11.5): - - GoogleUtilities/Logger - - GoogleUtilities/Network (7.11.5): - - GoogleUtilities/Logger - - "GoogleUtilities/NSData+zlib" - - GoogleUtilities/Reachability - "GoogleUtilities/NSData+zlib (7.11.5)" - - GoogleUtilities/Reachability (7.11.5): - - GoogleUtilities/Logger - GoogleUtilities/UserDefaults (7.11.5): - GoogleUtilities/Logger - KeychainSwift (20.0.0) - Kingfisher (7.9.1) - - nanopb (2.30909.0): - - nanopb/decode (= 2.30909.0) - - nanopb/encode (= 2.30909.0) - - nanopb/decode (2.30909.0) - - nanopb/encode (2.30909.0) + - nanopb (2.30909.1): + - nanopb/decode (= 2.30909.1) + - nanopb/encode (= 2.30909.1) + - nanopb/decode (2.30909.1) + - nanopb/encode (2.30909.1) - PromisesObjC (2.3.1) - PromisesSwift (2.3.1): - PromisesObjC (= 2.3.1) @@ -112,7 +62,6 @@ PODS: DEPENDENCIES: - Alamofire (~> 5.7) - - FirebaseAnalytics (~> 10.11) - FirebaseCrashlytics (~> 10.11) - KeychainSwift (~> 20.0) - Kingfisher (~> 7.8) @@ -125,14 +74,12 @@ DEPENDENCIES: SPEC REPOS: trunk: - Alamofire - - FirebaseAnalytics - FirebaseCore - FirebaseCoreExtension - FirebaseCoreInternal - FirebaseCrashlytics - FirebaseInstallations - FirebaseSessions - - GoogleAppMeasurement - GoogleDataTransport - GoogleUtilities - KeychainSwift @@ -158,19 +105,17 @@ CHECKOUT OPTIONS: SPEC CHECKSUMS: Alamofire: 0e92e751b3e9e66d7982db43919d01f313b8eb91 - FirebaseAnalytics: 47cef43728f81a839cf1306576bdd77ffa2eac7e FirebaseCore: 2cec518b43635f96afe7ac3a9c513e47558abd2e FirebaseCoreExtension: d3f1ea3725fb41f56e8fbfb29eeaff54e7ffb8f6 FirebaseCoreInternal: 2f4bee5ed00301b5e56da0849268797a2dd31fb4 FirebaseCrashlytics: a83f26fb922a3fe181eb738fb4dcf0c92bba6455 - FirebaseInstallations: cae95cab0f965ce05b805189de1d4c70b11c76fb + FirebaseInstallations: 9387bf15abfc69a714f54e54f74a251264fdb79b FirebaseSessions: ee59a7811bef4c15f65ef6472f3210faa293f9c8 - GoogleAppMeasurement: 722db6550d1e6d552b08398b69a975ac61039338 GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2 GoogleUtilities: 13e2c67ede716b8741c7989e26893d151b2b2084 KeychainSwift: 0ce6a4d13f7228054d1a71bb1b500448fb2ab837 Kingfisher: 1d14e9f59cbe19389f591c929000332bf70efd32 - nanopb: b552cce312b6c8484180ef47159bc0f65a1f0431 + nanopb: d4d75c12cd1316f4a64e3c6963f879ecd4b5e0d5 PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4 PromisesSwift: 28dca69a9c40779916ac2d6985a0192a5cb4a265 Sourcery: 6f5fe49b82b7e02e8c65560cbd52e1be67a1af2e @@ -180,6 +125,6 @@ SPEC CHECKSUMS: SwiftyMocky: c5e96e4ff76ec6dbf5a5941aeb039b5a546954a0 Swinject: 893c9a543000ac2f10ee4cbaf0933c6992c935d5 -PODFILE CHECKSUM: 544edab2f9ecc4ac18973fb8865f1d0613ec8a28 +PODFILE CHECKSUM: 9913958fea91fd668f5c10ff6d551637e7f3651c COCOAPODS: 1.14.2 From 2d2ade2265298aa0db94bd454d7e36d9499092da Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Tue, 6 Feb 2024 14:42:31 +0100 Subject: [PATCH 005/136] chore: returned back default values --- Podfile.lock | 2 +- default_config/config_settings.yaml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Podfile.lock b/Podfile.lock index 706da0909..451858fd5 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -127,4 +127,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 9913958fea91fd668f5c10ff6d551637e7f3651c -COCOAPODS: 1.14.2 +COCOAPODS: 1.15.0 diff --git a/default_config/config_settings.yaml b/default_config/config_settings.yaml index c63b34959..249e93fc3 100644 --- a/default_config/config_settings.yaml +++ b/default_config/config_settings.yaml @@ -1,4 +1,4 @@ -config_directory: './../edx-mobile-config' +config_directory: './default_config' config_mapping: prod: 'prod' stage: 'stage' From c1644951e7baef99e7c6c8fe81a87cdedb9d771e Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Tue, 6 Feb 2024 18:25:18 +0100 Subject: [PATCH 006/136] chore: added firebase as swift package --- Core/Core.xcodeproj/project.pbxproj | 33 ++++++++- .../Configuration/Config/FirebaseConfig.swift | 1 + OpenEdX.xcodeproj/project.pbxproj | 17 ----- OpenEdX/AppDelegate.swift | 8 +- .../AnalyticsManager/AnalyticsManager.swift | 6 +- Podfile | 2 +- Podfile.lock | 73 +------------------ 7 files changed, 40 insertions(+), 100 deletions(-) diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index d6fe7602f..c7faf4304 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -124,13 +124,15 @@ 0770DE6128D0B2CB006D8A5D /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE6028D0B2CB006D8A5D /* Assets.swift */; }; 07DDFCBD29A780BB00572595 /* UINavigationController+Animation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07DDFCBC29A780BB00572595 /* UINavigationController+Animation.swift */; }; 07E0939F2B308D2800F1E4B2 /* Data_Certificate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E0939E2B308D2800F1E4B2 /* Data_Certificate.swift */; }; + A51188632B729647004E9F8E /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = A51188622B729647004E9F8E /* FirebaseAnalytics */; }; + A51188652B72964F004E9F8E /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = A51188642B72964F004E9F8E /* FirebaseCrashlytics */; }; A51CDBE72B6D21F2009B6D4E /* SegmentConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51CDBE62B6D21F2009B6D4E /* SegmentConfig.swift */; }; A53A32352B233DEC005FE38A /* ThemeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A32342B233DEC005FE38A /* ThemeConfig.swift */; }; A595689B2B6173DF00ED4F90 /* BranchConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A595689A2B6173DF00ED4F90 /* BranchConfig.swift */; }; A5F4E7B52B61544A00ACD166 /* BrazeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */; }; BA30427F2B20B320009B64B7 /* SocialAuthError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA30427D2B20B299009B64B7 /* SocialAuthError.swift */; }; - BA4AFB442B6A5AF100A21367 /* CheckBoxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA4AFB432B6A5AF100A21367 /* CheckBoxView.swift */; }; BA4AFB422B5A7A0900A21367 /* VideoDownloadQualityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA4AFB412B5A7A0900A21367 /* VideoDownloadQualityView.swift */; }; + BA4AFB442B6A5AF100A21367 /* CheckBoxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA4AFB432B6A5AF100A21367 /* CheckBoxView.swift */; }; BA593F1C2AF8E498009ADB51 /* ScrollSlidingTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA593F1B2AF8E498009ADB51 /* ScrollSlidingTabBar.swift */; }; BA593F1E2AF8E4A0009ADB51 /* FrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA593F1D2AF8E4A0009ADB51 /* FrameReader.swift */; }; BA76135C2B21BC7300B599B7 /* SocialAuthResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA76135B2B21BC7300B599B7 /* SocialAuthResponse.swift */; }; @@ -141,9 +143,9 @@ BA8FA66C2AD59BBC00EA029A /* GoogleSignIn in Frameworks */ = {isa = PBXBuildFile; productRef = BA8FA66B2AD59BBC00EA029A /* GoogleSignIn */; }; BA8FA66E2AD59E7D00EA029A /* FacebookAuthProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA8FA66D2AD59E7D00EA029A /* FacebookAuthProvider.swift */; }; BA8FA6702AD59EA300EA029A /* MicrosoftAuthProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA8FA66F2AD59EA300EA029A /* MicrosoftAuthProvider.swift */; }; + BAAD62C62AFCF00B000E6103 /* CustomDisclosureGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAAD62C52AFCF00B000E6103 /* CustomDisclosureGroup.swift */; }; BAD9CA2F2B289B3500DE790A /* ajaxHandler.js in Resources */ = {isa = PBXBuildFile; fileRef = BAD9CA2E2B289B3500DE790A /* ajaxHandler.js */; }; BAD9CA332B28A8F300DE790A /* AjaxProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA322B28A8F300DE790A /* AjaxProvider.swift */; }; - BAAD62C62AFCF00B000E6103 /* CustomDisclosureGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAAD62C52AFCF00B000E6103 /* CustomDisclosureGroup.swift */; }; BAD9CA422B2B140100DE790A /* AgreementConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */; }; BADB3F5B2AD6EC56004D5CFA /* ResultExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADB3F5A2AD6EC56004D5CFA /* ResultExtension.swift */; }; BAF0D4CB2AD6AE14007AC334 /* FacebookLogin in Frameworks */ = {isa = PBXBuildFile; productRef = BAF0D4CA2AD6AE14007AC334 /* FacebookLogin */; }; @@ -305,8 +307,8 @@ A595689A2B6173DF00ED4F90 /* BranchConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BranchConfig.swift; sourceTree = ""; }; A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrazeConfig.swift; sourceTree = ""; }; BA30427D2B20B299009B64B7 /* SocialAuthError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SocialAuthError.swift; sourceTree = ""; }; - BA4AFB432B6A5AF100A21367 /* CheckBoxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckBoxView.swift; sourceTree = ""; }; BA4AFB412B5A7A0900A21367 /* VideoDownloadQualityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadQualityView.swift; sourceTree = ""; }; + BA4AFB432B6A5AF100A21367 /* CheckBoxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckBoxView.swift; sourceTree = ""; }; BA593F1B2AF8E498009ADB51 /* ScrollSlidingTabBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollSlidingTabBar.swift; sourceTree = ""; }; BA593F1D2AF8E4A0009ADB51 /* FrameReader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrameReader.swift; sourceTree = ""; }; BA76135B2B21BC7300B599B7 /* SocialAuthResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialAuthResponse.swift; sourceTree = ""; }; @@ -316,9 +318,9 @@ BA8FA6692AD59B5500EA029A /* GoogleAuthProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleAuthProvider.swift; sourceTree = ""; }; BA8FA66D2AD59E7D00EA029A /* FacebookAuthProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FacebookAuthProvider.swift; sourceTree = ""; }; BA8FA66F2AD59EA300EA029A /* MicrosoftAuthProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrosoftAuthProvider.swift; sourceTree = ""; }; + BAAD62C52AFCF00B000E6103 /* CustomDisclosureGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDisclosureGroup.swift; sourceTree = ""; }; BAD9CA2E2B289B3500DE790A /* ajaxHandler.js */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.javascript; path = ajaxHandler.js; sourceTree = ""; }; BAD9CA322B28A8F300DE790A /* AjaxProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AjaxProvider.swift; sourceTree = ""; }; - BAAD62C52AFCF00B000E6103 /* CustomDisclosureGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDisclosureGroup.swift; sourceTree = ""; }; BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementConfigTests.swift; sourceTree = ""; }; BADB3F5A2AD6EC56004D5CFA /* ResultExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResultExtension.swift; sourceTree = ""; }; BAFB99812B0E2354007D09F9 /* FacebookConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FacebookConfig.swift; sourceTree = ""; }; @@ -352,6 +354,8 @@ files = ( BAF0D4CB2AD6AE14007AC334 /* FacebookLogin in Frameworks */, 025EF2F62971740000B838AB /* YouTubePlayerKit in Frameworks */, + A51188632B729647004E9F8E /* FirebaseAnalytics in Frameworks */, + A51188652B72964F004E9F8E /* FirebaseCrashlytics in Frameworks */, C8C446EF233F81B9FABB77D2 /* Pods_App_Core.framework in Frameworks */, BA8FA66C2AD59BBC00EA029A /* GoogleSignIn in Frameworks */, E055A5392B18DC95008D9E5E /* Theme.framework in Frameworks */, @@ -867,6 +871,8 @@ 025EF2F52971740000B838AB /* YouTubePlayerKit */, BA8FA66B2AD59BBC00EA029A /* GoogleSignIn */, BAF0D4CA2AD6AE14007AC334 /* FacebookLogin */, + A51188622B729647004E9F8E /* FirebaseAnalytics */, + A51188642B72964F004E9F8E /* FirebaseCrashlytics */, ); productName = Core; productReference = 0770DE0828D07831006D8A5D /* Core.framework */; @@ -906,6 +912,7 @@ 025EF2F42971740000B838AB /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */, BA8FA65E2AD574D700EA029A /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */, BA8FA6712AD6ABA300EA029A /* XCRemoteSwiftPackageReference "facebook-ios-sdk" */, + A51188612B7295F7004E9F8E /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); productRefGroup = 0770DE0928D07831006D8A5D /* Products */; projectDirPath = ""; @@ -2160,6 +2167,14 @@ minimumVersion = 1.5.0; }; }; + A51188612B7295F7004E9F8E /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 10.20.0; + }; + }; BA8FA65E2AD574D700EA029A /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/google/GoogleSignIn-iOS"; @@ -2184,6 +2199,16 @@ package = 025EF2F42971740000B838AB /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */; productName = YouTubePlayerKit; }; + A51188622B729647004E9F8E /* FirebaseAnalytics */ = { + isa = XCSwiftPackageProductDependency; + package = A51188612B7295F7004E9F8E /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalytics; + }; + A51188642B72964F004E9F8E /* FirebaseCrashlytics */ = { + isa = XCSwiftPackageProductDependency; + package = A51188612B7295F7004E9F8E /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseCrashlytics; + }; BA8FA66B2AD59BBC00EA029A /* GoogleSignIn */ = { isa = XCSwiftPackageProductDependency; package = BA8FA65E2AD574D700EA029A /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */; diff --git a/Core/Core/Configuration/Config/FirebaseConfig.swift b/Core/Core/Configuration/Config/FirebaseConfig.swift index ba4a6f020..e8180f574 100644 --- a/Core/Core/Configuration/Config/FirebaseConfig.swift +++ b/Core/Core/Configuration/Config/FirebaseConfig.swift @@ -91,6 +91,7 @@ public final class FirebaseConfig: NSObject { firebaseOptions.clientID = clientID firebaseOptions.storageBucket = storageBucket firebaseOptions.databaseURL = databaseURL + return firebaseOptions } return nil diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 13265bdee..494ac21c3 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -51,7 +51,6 @@ A50066932B614DCD0024680B /* FCMListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50066922B614DCD0024680B /* FCMListener.swift */; }; A50066952B614DEF0024680B /* BrazeListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50066942B614DEF0024680B /* BrazeListener.swift */; }; A51CDBE52B6D1E93009B6D4E /* Segment in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBE42B6D1E93009B6D4E /* Segment */; }; - A51CDBEA2B6D25C9009B6D4E /* SegmentFirebase in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBE92B6D25C9009B6D4E /* SegmentFirebase */; }; A51CDBED2B6D2BEE009B6D4E /* SegmentBraze in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBEC2B6D2BEE009B6D4E /* SegmentBraze */; }; A51CDBEF2B6D2BEE009B6D4E /* SegmentBrazeUI in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBEE2B6D2BEE009B6D4E /* SegmentBrazeUI */; }; A59568952B61630500ED4F90 /* DeepLinkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568942B61630500ED4F90 /* DeepLinkManager.swift */; }; @@ -157,7 +156,6 @@ A51CDBE52B6D1E93009B6D4E /* Segment in Frameworks */, 072787B128D34D83002E9142 /* Discovery.framework in Frameworks */, 0218196428F734FA00202564 /* Discussion.framework in Frameworks */, - A51CDBEA2B6D25C9009B6D4E /* SegmentFirebase in Frameworks */, 0219C67728F4347600D64452 /* Course.framework in Frameworks */, 027DB33028D8A063002B6862 /* Dashboard.framework in Frameworks */, 95C140F3BDF778364986E83B /* Pods_App_OpenEdX.framework in Frameworks */, @@ -363,7 +361,6 @@ packageProductDependencies = ( BA3042782B1F7147009B64B7 /* MSAL */, A51CDBE42B6D1E93009B6D4E /* Segment */, - A51CDBE92B6D25C9009B6D4E /* SegmentFirebase */, A51CDBEC2B6D2BEE009B6D4E /* SegmentBraze */, A51CDBEE2B6D2BEE009B6D4E /* SegmentBrazeUI */, ); @@ -399,7 +396,6 @@ packageReferences = ( BA3042772B1F7147009B64B7 /* XCRemoteSwiftPackageReference "microsoft-authentication-library-for-objc" */, A51CDBE32B6D1E93009B6D4E /* XCRemoteSwiftPackageReference "analytics-swift" */, - A51CDBE82B6D25C9009B6D4E /* XCRemoteSwiftPackageReference "analytics-swift-firebase" */, A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */, ); productRefGroup = 07D5DA3228D075AA00752FD9 /* Products */; @@ -1166,14 +1162,6 @@ minimumVersion = 1.5.2; }; }; - A51CDBE82B6D25C9009B6D4E /* XCRemoteSwiftPackageReference "analytics-swift-firebase" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/segment-integrations/analytics-swift-firebase"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.3.4; - }; - }; A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/braze-inc/braze-segment-swift"; @@ -1198,11 +1186,6 @@ package = A51CDBE32B6D1E93009B6D4E /* XCRemoteSwiftPackageReference "analytics-swift" */; productName = Segment; }; - A51CDBE92B6D25C9009B6D4E /* SegmentFirebase */ = { - isa = XCSwiftPackageProductDependency; - package = A51CDBE82B6D25C9009B6D4E /* XCRemoteSwiftPackageReference "analytics-swift-firebase" */; - productName = SegmentFirebase; - }; A51CDBEC2B6D2BEE009B6D4E /* SegmentBraze */ = { isa = XCSwiftPackageProductDependency; package = A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */; diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index 53dada485..6e15bd11d 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -18,7 +18,7 @@ import MSAL import Theme //import BrazeKit import Segment -import SegmentFirebase +//import SegmentFirebase @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { @@ -60,9 +60,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { .trackApplicationLifecycleEvents(true) .flushInterval(10) analytics = Analytics(configuration: configuration) - if config.firebase.isAnalyticsSourceSegment { - analytics?.add(plugin: FirebaseDestination()) - } +// if config.firebase.isAnalyticsSourceSegment { +// analytics?.add(plugin: FirebaseDestination()) +// } } } diff --git a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift index 8d106c2af..9e3aa6e7f 100644 --- a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift +++ b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift @@ -7,7 +7,7 @@ import Foundation import Core -//import FirebaseAnalytics +import FirebaseAnalytics import Authorization import Discovery import Dashboard @@ -25,7 +25,8 @@ class AnalyticsManager: AuthorizationAnalytics, DiscussionAnalytics { public func identify(id: String, username: String, email: String) { - let traits : [String: String] = [ + Analytics.setUserID(id) + let traits: [String: String] = [ "email": email, "username": username ] @@ -314,6 +315,7 @@ class AnalyticsManager: AuthorizationAnalytics, } private func logEvent(_ event: Event, parameters: [String: Any]? = nil) { + Analytics.logEvent(event.rawValue, parameters: parameters) (UIApplication.shared.delegate as? AppDelegate)?.analytics?.track( name: event.rawValue, properties: parameters diff --git a/Podfile b/Podfile index c14e319bd..0a785d68f 100644 --- a/Podfile +++ b/Podfile @@ -18,7 +18,7 @@ abstract_target "App" do workspace './Core/Core.xcodeproj' #Firebase # pod 'FirebaseAnalytics', '~> 10.11' - pod 'FirebaseCrashlytics', '~> 10.11' +# pod 'FirebaseCrashlytics', '~> 10.11' #Networking pod 'Alamofire', '~> 5.7' #Keychain diff --git a/Podfile.lock b/Podfile.lock index 451858fd5..efae391c2 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,55 +1,7 @@ PODS: - Alamofire (5.8.0) - - FirebaseCore (10.15.0): - - FirebaseCoreInternal (~> 10.0) - - GoogleUtilities/Environment (~> 7.8) - - GoogleUtilities/Logger (~> 7.8) - - FirebaseCoreExtension (10.15.0): - - FirebaseCore (~> 10.0) - - FirebaseCoreInternal (10.15.0): - - "GoogleUtilities/NSData+zlib (~> 7.8)" - - FirebaseCrashlytics (10.15.0): - - FirebaseCore (~> 10.5) - - FirebaseInstallations (~> 10.0) - - FirebaseSessions (~> 10.5) - - GoogleDataTransport (~> 9.2) - - GoogleUtilities/Environment (~> 7.8) - - nanopb (< 2.30910.0, >= 2.30908.0) - - PromisesObjC (~> 2.1) - - FirebaseInstallations (10.17.0): - - FirebaseCore (~> 10.0) - - GoogleUtilities/Environment (~> 7.8) - - GoogleUtilities/UserDefaults (~> 7.8) - - PromisesObjC (~> 2.1) - - FirebaseSessions (10.15.0): - - FirebaseCore (~> 10.5) - - FirebaseCoreExtension (~> 10.0) - - FirebaseInstallations (~> 10.0) - - GoogleDataTransport (~> 9.2) - - GoogleUtilities/Environment (~> 7.10) - - nanopb (< 2.30910.0, >= 2.30908.0) - - PromisesSwift (~> 2.1) - - GoogleDataTransport (9.2.5): - - GoogleUtilities/Environment (~> 7.7) - - nanopb (< 2.30910.0, >= 2.30908.0) - - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/Environment (7.11.5): - - PromisesObjC (< 3.0, >= 1.2) - - GoogleUtilities/Logger (7.11.5): - - GoogleUtilities/Environment - - "GoogleUtilities/NSData+zlib (7.11.5)" - - GoogleUtilities/UserDefaults (7.11.5): - - GoogleUtilities/Logger - KeychainSwift (20.0.0) - Kingfisher (7.9.1) - - nanopb (2.30909.1): - - nanopb/decode (= 2.30909.1) - - nanopb/encode (= 2.30909.1) - - nanopb/decode (2.30909.1) - - nanopb/encode (2.30909.1) - - PromisesObjC (2.3.1) - - PromisesSwift (2.3.1): - - PromisesObjC (= 2.3.1) - Sourcery (1.8.0): - Sourcery/CLI-Only (= 1.8.0) - Sourcery/CLI-Only (1.8.0) @@ -62,7 +14,6 @@ PODS: DEPENDENCIES: - Alamofire (~> 5.7) - - FirebaseCrashlytics (~> 10.11) - KeychainSwift (~> 20.0) - Kingfisher (~> 7.8) - SwiftGen (~> 6.6) @@ -74,19 +25,8 @@ DEPENDENCIES: SPEC REPOS: trunk: - Alamofire - - FirebaseCore - - FirebaseCoreExtension - - FirebaseCoreInternal - - FirebaseCrashlytics - - FirebaseInstallations - - FirebaseSessions - - GoogleDataTransport - - GoogleUtilities - KeychainSwift - Kingfisher - - nanopb - - PromisesObjC - - PromisesSwift - Sourcery - SwiftGen - SwiftLint @@ -105,19 +45,8 @@ CHECKOUT OPTIONS: SPEC CHECKSUMS: Alamofire: 0e92e751b3e9e66d7982db43919d01f313b8eb91 - FirebaseCore: 2cec518b43635f96afe7ac3a9c513e47558abd2e - FirebaseCoreExtension: d3f1ea3725fb41f56e8fbfb29eeaff54e7ffb8f6 - FirebaseCoreInternal: 2f4bee5ed00301b5e56da0849268797a2dd31fb4 - FirebaseCrashlytics: a83f26fb922a3fe181eb738fb4dcf0c92bba6455 - FirebaseInstallations: 9387bf15abfc69a714f54e54f74a251264fdb79b - FirebaseSessions: ee59a7811bef4c15f65ef6472f3210faa293f9c8 - GoogleDataTransport: 54dee9d48d14580407f8f5fbf2f496e92437a2f2 - GoogleUtilities: 13e2c67ede716b8741c7989e26893d151b2b2084 KeychainSwift: 0ce6a4d13f7228054d1a71bb1b500448fb2ab837 Kingfisher: 1d14e9f59cbe19389f591c929000332bf70efd32 - nanopb: d4d75c12cd1316f4a64e3c6963f879ecd4b5e0d5 - PromisesObjC: c50d2056b5253dadbd6c2bea79b0674bd5a52fa4 - PromisesSwift: 28dca69a9c40779916ac2d6985a0192a5cb4a265 Sourcery: 6f5fe49b82b7e02e8c65560cbd52e1be67a1af2e SwiftGen: 1366a7f71aeef49954ca5a63ba4bef6b0f24138c SwiftLint: 5ce4d6a8ff83f1b5fd5ad5dbf30965d35af65e44 @@ -125,6 +54,6 @@ SPEC CHECKSUMS: SwiftyMocky: c5e96e4ff76ec6dbf5a5941aeb039b5a546954a0 Swinject: 893c9a543000ac2f10ee4cbaf0933c6992c935d5 -PODFILE CHECKSUM: 9913958fea91fd668f5c10ff6d551637e7f3651c +PODFILE CHECKSUM: b4e49ed566507f9bbf9bcff18accdca28f28e106 COCOAPODS: 1.15.0 From f9c681335c20159bb6e359e9640bcb36d8ad530d Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Wed, 7 Feb 2024 15:07:57 +0100 Subject: [PATCH 007/136] chore: fixed upload crashlytic dsym build phase script --- OpenEdX.xcodeproj/project.pbxproj | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 494ac21c3..aacd4d74e 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -431,6 +431,11 @@ inputFileListPaths = ( ); inputPaths = ( + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}", + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Resources/DWARF/${PRODUCT_NAME}", + "${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist", + "$(TARGET_BUILD_DIR)/$(UNLOCALIZED_RESOURCES_FOLDER_PATH)/GoogleService-Info.plist", + "$(TARGET_BUILD_DIR)/$(EXECUTABLE_PATH)", ); name = FirebaseCrashlytics; outputFileListPaths = ( @@ -439,7 +444,7 @@ ); runOnlyForDeploymentPostprocessing = 1; shellPath = /bin/sh; - shellScript = "plistPath=\"${BUILT_PRODUCTS_DIR}/${WRAPPER_NAME}/GoogleService-Info.plist\"\n\ngoogleAppID=$(/usr/libexec/PlistBuddy -c \"Print :GOOGLE_APP_ID\" \"$plistPath\")\n\nif [ -z \"$googleAppID\" ]\nthen\n echo \"GoogleAppID is empty. The FirebaseCrashlytics script will be skipped.\"\nelse\n \"${PODS_ROOT}/FirebaseCrashlytics/run\" --app-id \"$googleAppID\" -p ios \"${DWARF_DSYM_FOLDER_PATH}\"\nfi\n"; + shellScript = "plistPath=\"${BUILT_PRODUCTS_DIR}/${WRAPPER_NAME}/GoogleService-Info.plist\"\n\ngoogleAppID=$(/usr/libexec/PlistBuddy -c \"Print :GOOGLE_APP_ID\" \"$plistPath\")\n\nif [ -z \"$googleAppID\" ]\nthen\n echo \"GoogleAppID is empty. The FirebaseCrashlytics script will be skipped.\"\nelse\n \"${BUILD_DIR%/Build/*}/SourcePackages/checkouts/firebase-ios-sdk/Crashlytics/run\"\nfi\n"; }; 0770DE2328D08647006D8A5D /* SwiftLint */ = { isa = PBXShellScriptBuildPhase; From 164a0787c11fee156b96a1e04d78d0e463f32171 Mon Sep 17 00:00:00 2001 From: saeedbashir Date: Thu, 11 Jan 2024 13:07:01 +0500 Subject: [PATCH 008/136] chore: enhancing app theme capability by changing adding colors --- .../Presentation/Base/FieldsView.swift | 4 +- .../Presentation/Login/SignInView.swift | 6 +-- .../Registration/SignUpView.swift | 1 + .../Extensions/UIApplicationExtension.swift | 7 ++-- Core/Core/View/Base/AlertView.swift | 15 ++++---- .../View/Base/LogistrationBottomView.swift | 6 +-- Core/Core/View/Base/NavigationBar.swift | 9 +++-- Core/Core/View/Base/OfflineSnackBarView.swift | 2 +- Core/Core/View/Base/StyledButton.swift | 13 ++----- Core/Core/View/Base/UnitButtonView.swift | 16 +++++--- .../Comments/Base/ParentCommentView.swift | 2 +- .../CreateNewThread/CreateNewThreadView.swift | 4 +- .../Presentation/Posts/PostsView.swift | 13 ++++--- OpenEdX/Base.lproj/LaunchScreen.storyboard | 5 ++- OpenEdX/View/MainScreenView.swift | 6 +-- .../DeleteAccount/DeleteAccountView.swift | 18 +++++++-- .../EditProfile/EditProfileView.swift | 6 +-- .../Presentation/Profile/ProfileView.swift | 2 + .../Subviews/ProfileSupportInfoView.swift | 1 + .../Presentation/Settings/SettingsView.swift | 2 +- .../Settings/VideoQualityView.swift | 2 +- .../AccentXColor.colorset/Contents.json | 38 +++++++++++++++++++ .../Contents.json | 38 +++++++++++++++++++ .../Contents.json | 38 +++++++++++++++++++ .../Contents.json | 38 +++++++++++++++++++ .../TextSecondaryLight.colorset/Contents.json | 38 +++++++++++++++++++ .../Contents.json | 10 ++--- .../appLogoLight.imageset/Contents.json | 21 ++++++++++ .../appLogoLight.imageset/appLogoWhite.svg | 4 ++ Theme/Theme/SwiftGen/ThemeAssets.swift | 8 +++- Theme/Theme/Theme.swift | 32 ++++++++++++---- .../Presentation/Elements/PageControl.swift | 2 +- .../Elements/WhatsNewNavigationButton.swift | 6 +-- .../WhatsNew/Presentation/WhatsNewView.swift | 2 +- 34 files changed, 337 insertions(+), 78 deletions(-) create mode 100644 Theme/Theme/Assets.xcassets/Colors/AccentXColor.colorset/Contents.json create mode 100644 Theme/Theme/Assets.xcassets/Colors/SecondardButtonBorderColor.colorset/Contents.json create mode 100644 Theme/Theme/Assets.xcassets/Colors/SecondardButtonTextColor.colorset/Contents.json create mode 100644 Theme/Theme/Assets.xcassets/Colors/Snackbar/SnackbarWarningColor.colorset/Contents.json create mode 100644 Theme/Theme/Assets.xcassets/Colors/TextColor/TextSecondaryLight.colorset/Contents.json rename Theme/Theme/Assets.xcassets/Colors/{Snackbar/SnackbarErrorTextColor.colorset => navigationBarTintColor.colorset}/Contents.json (80%) create mode 100644 Theme/Theme/Assets.xcassets/appLogoLight.imageset/Contents.json create mode 100644 Theme/Theme/Assets.xcassets/appLogoLight.imageset/appLogoWhite.svg diff --git a/Authorization/Authorization/Presentation/Base/FieldsView.swift b/Authorization/Authorization/Presentation/Base/FieldsView.swift index 6abe1858e..d05eac80f 100644 --- a/Authorization/Authorization/Presentation/Base/FieldsView.swift +++ b/Authorization/Authorization/Presentation/Base/FieldsView.swift @@ -94,8 +94,8 @@ struct FieldsView: View { } } Text(.init(text)) - .tint(Theme.Colors.accentColor) - .foregroundStyle(Theme.Colors.textSecondary) + .tint(Theme.Colors.accentXColor) + .foregroundStyle(Theme.Colors.textSecondaryLight) .font(Theme.Fonts.labelSmall) .padding(.vertical, 3) .id(UUID()) diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index fbd015c14..13641450c 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -48,7 +48,7 @@ public struct SignInView: View { } VStack(alignment: .center) { - ThemeAssets.appLogo.swiftUIImage + ThemeAssets.appLogoLight.swiftUIImage .resizable() .frame(maxWidth: 189, maxHeight: 54) .padding(.top, isHorizontal ? 20 : 40) @@ -214,8 +214,8 @@ public struct SignInView: View { policy ) Text(.init(text)) - .tint(Theme.Colors.accentColor) - .foregroundStyle(Theme.Colors.textSecondary) + .tint(Theme.Colors.accentXColor) + .foregroundStyle(Theme.Colors.textSecondaryLight) .font(Theme.Fonts.labelSmall) .padding(.top, viewModel.socialAuthEnabled ? 0 : 15) .padding(.bottom, 15) diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index 3109c367a..40a851ef3 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -114,6 +114,7 @@ public struct SignUpView: View { } .accessibilityLabel("optional_fields_text") .padding(.top, 10) + .foregroundColor(Theme.Colors.accentXColor) } FieldsView( diff --git a/Core/Core/Extensions/UIApplicationExtension.swift b/Core/Core/Extensions/UIApplicationExtension.swift index 022c1770a..871a60b7b 100644 --- a/Core/Core/Extensions/UIApplicationExtension.swift +++ b/Core/Core/Extensions/UIApplicationExtension.swift @@ -45,10 +45,11 @@ extension UINavigationController { navigationBar.shadowImage = UIImage() let image = CoreAssets.arrowLeft.image - navigationBar.backIndicatorImage = image.withTintColor(Theme.UIColors.accentColor) + navigationBar.backIndicatorImage = image.withTintColor(Theme.UIColors.accentXColor) navigationBar.backItem?.backButtonTitle = " " - navigationBar.backIndicatorTransitionMaskImage = image.withTintColor(Theme.UIColors.accentColor) - navigationBar.titleTextAttributes = [.foregroundColor: Theme.UIColors.textPrimary] + navigationBar.backIndicatorTransitionMaskImage = image.withTintColor(Theme.UIColors.accentXColor) + navigationBar.titleTextAttributes = [.foregroundColor: Theme.UIColors.navigationBarTintColor] + UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = Theme.UIColors.accentColor } } diff --git a/Core/Core/View/Base/AlertView.swift b/Core/Core/View/Base/AlertView.swift index ad501bdb9..0f71e1ff2 100644 --- a/Core/Core/View/Base/AlertView.swift +++ b/Core/Core/View/Base/AlertView.swift @@ -198,19 +198,19 @@ public struct AlertView: View { }, label: { ZStack { Text(CoreLocalization.Alert.logout) - .foregroundColor(.black) + .foregroundColor(Theme.Colors.white) .font(Theme.Fonts.labelLarge) .frame(maxWidth: .infinity) .padding(.horizontal, 16) Image(systemName: "rectangle.portrait.and.arrow.right") - .foregroundColor(.black) + .foregroundColor(Theme.Colors.white) .frame(minWidth: 190, minHeight: 48, alignment: .trailing) } .frame(maxWidth: 215, minHeight: 48) }) .background( Theme.Shapes.buttonShape - .fill(Theme.Colors.warning) + .fill(Theme.Colors.accentColor) ) .overlay( RoundedRectangle(cornerRadius: 8) @@ -230,7 +230,7 @@ public struct AlertView: View { }, label: { ZStack { Text(CoreLocalization.Alert.leave) - .foregroundColor(.black) + .foregroundColor(Theme.Colors.white) .font(Theme.Fonts.labelLarge) .frame(maxWidth: .infinity) .padding(.horizontal, 16) @@ -239,7 +239,7 @@ public struct AlertView: View { }) .background( Theme.Shapes.buttonShape - .fill(Theme.Colors.warning) + .fill(Theme.Colors.accentColor) ) .overlay( RoundedRectangle(cornerRadius: 8) @@ -258,7 +258,7 @@ public struct AlertView: View { }, label: { ZStack { Text(CoreLocalization.Alert.keepEditing) - .foregroundColor(Theme.Colors.textPrimary) + .foregroundColor(Theme.Colors.accentColor) .font(Theme.Fonts.labelLarge) .frame(maxWidth: .infinity) .padding(.horizontal, 16) @@ -277,7 +277,7 @@ public struct AlertView: View { lineJoin: .round, miterLimit: 1 )) - .foregroundColor(Theme.Colors.textPrimary) + .foregroundColor(Theme.Colors.secondardButtonBorderColor) ) .frame(maxWidth: 215) }.padding(.trailing, isHorizontal ? 20 : 0) @@ -292,6 +292,7 @@ public struct AlertView: View { Image(systemName: "xmark") .padding(.trailing, 40) .padding(.top, 24) + .foregroundColor(Theme.Colors.accentXColor) }) }.frame(maxWidth: type == .logOut ? 390 : nil) diff --git a/Core/Core/View/Base/LogistrationBottomView.swift b/Core/Core/View/Base/LogistrationBottomView.swift index 2272997e2..3a0b88fbd 100644 --- a/Core/Core/View/Base/LogistrationBottomView.swift +++ b/Core/Core/View/Base/LogistrationBottomView.swift @@ -45,9 +45,9 @@ public struct LogistrationBottomView: View { action: { action(.signIn) }, - color: .white, - textColor: Theme.Colors.accentColor, - borderColor: Theme.Colors.textInputStroke + color: Theme.Colors.white, + textColor: Theme.Colors.secondardButtonTextColor, + borderColor: Theme.Colors.secondardButtonBorderColor ) .frame(width: 100) .accessibilityIdentifier("logistration_signin_button") diff --git a/Core/Core/View/Base/NavigationBar.swift b/Core/Core/View/Base/NavigationBar.swift index e90c2d459..95ce1dae0 100644 --- a/Core/Core/View/Base/NavigationBar.swift +++ b/Core/Core/View/Base/NavigationBar.swift @@ -25,8 +25,8 @@ public struct NavigationBar: View { @Binding private var rightButtonIsActive: Bool public init(title: String, - titleColor: Color = Theme.Colors.textPrimary, - leftButtonColor: Color = Theme.Colors.accentColor, + titleColor: Color = Theme.Colors.navigationBarTintColor, + leftButtonColor: Color = Theme.Colors.accentXColor, leftButtonAction: (() -> Void)? = nil, rightButtonType: ButtonType? = nil, rightButtonAction: (() -> Void)? = nil, @@ -81,14 +81,15 @@ public struct NavigationBar: View { .backButtonStyle(topPadding: 0) Text(CoreLocalization.done) .font(Theme.Fonts.labelLarge) - .foregroundColor(Theme.Colors.accentColor) + .foregroundColor(Theme.Colors.accentXColor) }.offset(y: -6) case .edit: - CoreAssets.edit.swiftUIImage + CoreAssets.edit.swiftUIImage.renderingMode(.template) .resizable() .frame(width: 24, height: 24) .padding(.horizontal) .padding(.top, -10) + .foregroundColor(Theme.Colors.accentXColor) case .none: EmptyView() } diff --git a/Core/Core/View/Base/OfflineSnackBarView.swift b/Core/Core/View/Base/OfflineSnackBarView.swift index 2813d0cc9..b2625f7f8 100644 --- a/Core/Core/View/Base/OfflineSnackBarView.swift +++ b/Core/Core/View/Base/OfflineSnackBarView.swift @@ -47,7 +47,7 @@ public struct OfflineSnackBarView: View { }.padding(.horizontal, 16) .font(Theme.Fonts.titleSmall) .frame(maxWidth: .infinity, maxHeight: OfflineSnackBarView.height) - .background(Theme.Colors.warning.ignoresSafeArea()) + .background(Theme.Colors.snackbarWarningColor.ignoresSafeArea()) } } .onAppear { diff --git a/Core/Core/View/Base/StyledButton.swift b/Core/Core/View/Base/StyledButton.swift index 7190c2469..0de93feed 100644 --- a/Core/Core/View/Base/StyledButton.swift +++ b/Core/Core/View/Base/StyledButton.swift @@ -15,7 +15,6 @@ public struct StyledButton: View { private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } private let buttonColor: Color private let textColor: Color - private let disabledTextColor: Color private let isActive: Bool private let borderColor: Color @@ -24,21 +23,14 @@ public struct StyledButton: View { isTransparent: Bool = false, color: Color = Theme.Colors.accentButtonColor, textColor: Color = Theme.Colors.styledButtonText, - disabledTextColor: Color = Theme.Colors.textPrimary, borderColor: Color = .clear, isActive: Bool = true) { self.title = title self.action = action self.isTransparent = isTransparent self.textColor = textColor - self.disabledTextColor = disabledTextColor self.borderColor = borderColor - - if isActive { - self.buttonColor = color - } else { - self.buttonColor = Theme.Colors.cardViewStroke - } + self.buttonColor = color self.isActive = isActive } @@ -46,7 +38,7 @@ public struct StyledButton: View { Button(action: action) { Text(title) .tracking(isTransparent ? 0 : 1.3) - .foregroundColor(isActive ? textColor : disabledTextColor) + .foregroundColor(textColor) .font(Theme.Fonts.labelLarge) .frame(maxWidth: .infinity) .padding(.horizontal, 16) @@ -65,6 +57,7 @@ public struct StyledButton: View { ) .accessibilityElement(children: .ignore) .accessibilityLabel(title) + .opacity(isActive ? 1.0 : 0.3) } } diff --git a/Core/Core/View/Base/UnitButtonView.swift b/Core/Core/View/Base/UnitButtonView.swift index 3e6890d8e..f447a05fe 100644 --- a/Core/Core/View/Base/UnitButtonView.swift +++ b/Core/Core/View/Base/UnitButtonView.swift @@ -99,13 +99,13 @@ public struct UnitButtonView: View { HStack { if isVerticalNavigation { Text(type.stringValue()) - .foregroundColor(Theme.Colors.accentColor) + .foregroundColor(Theme.Colors.secondardButtonTextColor) .font(Theme.Fonts.labelLarge) .padding(.leading, 20) CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) .rotationEffect(Angle.degrees(90)) .padding(.trailing, 20) - .foregroundColor(Theme.Colors.accentColor) + .foregroundColor(Theme.Colors.secondardButtonTextColor) } else { CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) .padding(.leading, 20) @@ -139,7 +139,7 @@ public struct UnitButtonView: View { case .reload, .custom: VStack(alignment: .center) { Text(type.stringValue()) - .foregroundColor(bgColor == nil ? .white : Theme.Colors.accentColor) + .foregroundColor(bgColor == nil ? .white : Theme.Colors.secondardButtonTextColor) .font(Theme.Fonts.labelLarge) }.padding(.horizontal, 16) case .continueLesson, .nextSection: @@ -173,7 +173,10 @@ public struct UnitButtonView: View { lineJoin: .round, miterLimit: 1) ) - .foregroundColor(Theme.Colors.accentButtonColor) + .foregroundColor( + type == .previous ? Theme.Colors.secondardButtonBorderColor + : Theme.Colors.accentButtonColor + ) ) case .continueLesson, .nextSection, .reload, .finish, .custom: @@ -195,7 +198,10 @@ public struct UnitButtonView: View { lineJoin: .round, miterLimit: 1 )) - .foregroundColor(Theme.Colors.accentButtonColor) + .foregroundColor( + type == .continueLesson ? Theme.Colors.accentButtonColor + : Theme.Colors.secondardButtonBorderColor + ) ) } } diff --git a/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift b/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift index 1706bb75c..15ced3ce7 100644 --- a/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift +++ b/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift @@ -124,7 +124,7 @@ public struct ParentCommentView: View { }) } .accentColor(comments.abuseFlagged - ? Theme.Colors.snackbarErrorColor + ? Theme.Colors.alert : Theme.Colors.textSecondary) .font(Theme.Fonts.labelLarge) .padding(.top, 8) diff --git a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift index 5bb082051..f6f6a0dc4 100644 --- a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift +++ b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift @@ -172,9 +172,9 @@ public struct CreateNewThreadView: View { } } } - }) + }, + isActive: postTitle != "" && postBody != "") .padding(.top, 26) - .saturation(!postTitle.isEmpty && !postBody.isEmpty ? 1 : 0) Spacer() }.padding(.horizontal, 24) .frameLimit() diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index b99b81287..ab24def7c 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -66,7 +66,8 @@ public struct PostsView: View { viewModel.generateButtons(type: .filter) showingAlert = true }, label: { - CoreAssets.filter.swiftUIImage + CoreAssets.filter.swiftUIImage.renderingMode(.template) + .foregroundColor(Theme.Colors.accentXColor) Text(viewModel.filterTitle.localizedValue) }) Spacer() @@ -74,7 +75,8 @@ public struct PostsView: View { viewModel.generateButtons(type: .sort) showingAlert = true }, label: { - CoreAssets.sort.swiftUIImage + CoreAssets.sort.swiftUIImage.renderingMode(.template) + .foregroundColor(Theme.Colors.accentXColor) Text(viewModel.sortTitle.localizedValue) }) }.foregroundColor(Theme.Colors.accentColor) @@ -126,7 +128,7 @@ public struct PostsView: View { .foregroundColor(Theme.Colors.white) .background( Circle() - .foregroundColor(Theme.Colors.accentColor) + .foregroundColor(Theme.Colors.accentButtonColor) ) }) } @@ -180,7 +182,7 @@ public struct PostsView: View { isTransparent: true) .frame(width: 215) .padding(.top, 40) - .colorMultiply(.accentColor) + .colorMultiply(Theme.Colors.accentColor) }.padding(24) .padding(.top, 100) @@ -308,7 +310,8 @@ public struct PostCell: View { .font(Theme.Fonts.labelSmall) .foregroundColor(Theme.Colors.textSecondary) HStack { - CoreAssets.responses.swiftUIImage + CoreAssets.responses.swiftUIImage.renderingMode(.template) + .foregroundColor(Theme.Colors.accentXColor) Text("\(post.replies - 1)") Text(DiscussionLocalization.responsesCount(post.replies - 1)) .font(Theme.Fonts.labelLarge) diff --git a/OpenEdX/Base.lproj/LaunchScreen.storyboard b/OpenEdX/Base.lproj/LaunchScreen.storyboard index 5cb3986ad..ecc5f8915 100644 --- a/OpenEdX/Base.lproj/LaunchScreen.storyboard +++ b/OpenEdX/Base.lproj/LaunchScreen.storyboard @@ -1,9 +1,9 @@ - + - + @@ -38,6 +38,7 @@ + diff --git a/OpenEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift index 873350011..8971d982c 100644 --- a/OpenEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -36,7 +36,7 @@ struct MainScreenView: View { UITabBar.appearance().isTranslucent = false UITabBar.appearance().barTintColor = UIColor(Theme.Colors.textInputUnfocusedBackground) UITabBar.appearance().backgroundColor = UIColor(Theme.Colors.textInputUnfocusedBackground) - UITabBar.appearance().unselectedItemTintColor = UIColor(Theme.Colors.textSecondary) + UITabBar.appearance().unselectedItemTintColor = UIColor(Theme.Colors.textSecondaryLight) } var body: some View { @@ -127,8 +127,8 @@ struct MainScreenView: View { Button(action: { settingsTapped.toggle() }, label: { - CoreAssets.edit.swiftUIImage - .foregroundColor(Theme.Colors.textPrimary) + CoreAssets.edit.swiftUIImage.renderingMode(.template) + .foregroundColor(Theme.Colors.navigationBarTintColor) }) } else { VStack {} diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift index 36db20c35..e3af3e76a 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift @@ -33,7 +33,7 @@ public struct DeleteAccountView: View { .offset(x: -7, y: -27) }.padding(.top, 50) Text(ProfileLocalization.DeleteAccount.areYouSure) - .foregroundColor(Theme.Colors.textPrimary) + .foregroundColor(Theme.Colors.navigationBarTintColor) + Text(ProfileLocalization.DeleteAccount.wantToDelete) .foregroundColor(Theme.Colors.alert) }.multilineTextAlignment(.center) @@ -95,7 +95,7 @@ public struct DeleteAccountView: View { Task { try await viewModel.deleteAccount(password: viewModel.password) } - }, color: Theme.Colors.alert, + }, color: Theme.Colors.accentColor, isActive: viewModel.password.count >= 2) .padding(.top, 18) } @@ -107,12 +107,24 @@ public struct DeleteAccountView: View { HStack(spacing: 9) { CoreAssets.arrowRight16.swiftUIImage.renderingMode(.template) .rotationEffect(Angle(degrees: 180)) + .foregroundColor(Theme.Colors.accentColor) Text(ProfileLocalization.DeleteAccount.backToProfile) .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.accentColor) } }) - .padding(.top, 35) + .frame(maxWidth: .infinity, minHeight: 42) + .background( + Theme.Shapes.buttonShape + .fill(Theme.Colors.white) + ) + .overlay( + Theme.Shapes.buttonShape + .stroke(style: .init(lineWidth: 1, lineCap: .round, lineJoin: .round, miterLimit: 1)) + .foregroundColor(Theme.Colors.secondardButtonBorderColor) + ) + .padding(.top, 35) } }.padding(.horizontal, 24) .frame(minHeight: 0, diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift index b26f87c17..ffeecd11b 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift @@ -48,7 +48,7 @@ public struct EditProfileView: View { ZStack { Circle().frame(width: 36, height: 36) .foregroundColor(Theme.Colors.accentColor) - CoreAssets.addPhoto.swiftUIImage + CoreAssets.addPhoto.swiftUIImage.renderingMode(.template) .foregroundColor(Theme.Colors.white) }.offset(x: 36, y: 50) ) @@ -223,10 +223,10 @@ public struct EditProfileView: View { }, label: { HStack(spacing: 2) { CoreAssets.done.swiftUIImage.renderingMode(.template) - .foregroundColor(Theme.Colors.accentColor) + .foregroundColor(Theme.Colors.accentXColor) Text(CoreLocalization.done) .font(Theme.Fonts.labelLarge) - .foregroundColor(Theme.Colors.accentColor) + .foregroundColor(Theme.Colors.accentXColor) } }).opacity(viewModel.isChanged ? 1 : 0.3) }) diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index 0450da9c8..15b78dc88 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -132,6 +132,7 @@ public struct ProfileView: View { Text(ProfileLocalization.info) .padding(.horizontal, 24) .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textSecondary) VStack(alignment: .leading, spacing: 16) { if viewModel.userModel?.yearOfBirth != 0 { @@ -173,6 +174,7 @@ public struct ProfileView: View { Text(ProfileLocalization.settings) .padding(.horizontal, 24) .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textSecondary) VStack(alignment: .leading, spacing: 27) { Button(action: { viewModel.trackProfileVideoSettingsClicked() diff --git a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift index 81b669355..158e64075 100644 --- a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift +++ b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift @@ -22,6 +22,7 @@ struct ProfileSupportInfoView: View { Text(ProfileLocalization.supportInfo) .padding(.horizontal, 24) .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textSecondary) VStack(alignment: .leading, spacing: 24) { viewModel.contactSupport().map(supportInfo) viewModel.config.agreement.tosURL.map(terms) diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index 5ce74d71a..33eba42c2 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -37,7 +37,7 @@ public struct SettingsView: View { description: ProfileLocalization.Settings.wifiDescription ) Toggle(isOn: $viewModel.wifiOnly, label: {}) - .toggleStyle(SwitchToggleStyle(tint: .accentColor)) + .toggleStyle(SwitchToggleStyle(tint: Theme.Colors.accentColor)) .frame(width: 50) }.foregroundColor(Theme.Colors.textPrimary) Divider() diff --git a/Profile/Profile/Presentation/Settings/VideoQualityView.swift b/Profile/Profile/Presentation/Settings/VideoQualityView.swift index e347ee60d..8530b2dfd 100644 --- a/Profile/Profile/Presentation/Settings/VideoQualityView.swift +++ b/Profile/Profile/Presentation/Settings/VideoQualityView.swift @@ -42,7 +42,7 @@ public struct VideoQualityView: View { Spacer() CoreAssets.checkmark.swiftUIImage .renderingMode(.template) - .foregroundColor(.accentColor) + .foregroundColor(Theme.Colors.accentXColor) .opacity(quality == viewModel.selectedQuality ? 1 : 0) }.foregroundColor(Theme.Colors.textPrimary) diff --git a/Theme/Theme/Assets.xcassets/Colors/AccentXColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/AccentXColor.colorset/Contents.json new file mode 100644 index 000000000..00d59cb46 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/AccentXColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.408", + "red" : "0.235" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.976", + "green" : "0.471", + "red" : "0.329" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/Colors/SecondardButtonBorderColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/SecondardButtonBorderColor.colorset/Contents.json new file mode 100644 index 000000000..00d59cb46 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/SecondardButtonBorderColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.408", + "red" : "0.235" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.976", + "green" : "0.471", + "red" : "0.329" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/Colors/SecondardButtonTextColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/SecondardButtonTextColor.colorset/Contents.json new file mode 100644 index 000000000..00d59cb46 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/SecondardButtonTextColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.408", + "red" : "0.235" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.976", + "green" : "0.471", + "red" : "0.329" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/Colors/Snackbar/SnackbarWarningColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/Snackbar/SnackbarWarningColor.colorset/Contents.json new file mode 100644 index 000000000..db6a639f5 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/Snackbar/SnackbarWarningColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.282", + "green" : "0.761", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.282", + "green" : "0.761", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/Colors/TextColor/TextSecondaryLight.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/TextColor/TextSecondaryLight.colorset/Contents.json new file mode 100644 index 000000000..93691d3e8 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/TextColor/TextSecondaryLight.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.733", + "green" : "0.647", + "red" : "0.592" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.624", + "green" : "0.533", + "red" : "0.475" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/Colors/Snackbar/SnackbarErrorTextColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/navigationBarTintColor.colorset/Contents.json similarity index 80% rename from Theme/Theme/Assets.xcassets/Colors/Snackbar/SnackbarErrorTextColor.colorset/Contents.json rename to Theme/Theme/Assets.xcassets/Colors/navigationBarTintColor.colorset/Contents.json index 16da2b8fe..a3f0c654a 100644 --- a/Theme/Theme/Assets.xcassets/Colors/Snackbar/SnackbarErrorTextColor.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/navigationBarTintColor.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.129", + "blue" : "0.184", "green" : "0.129", - "red" : "0.129" + "red" : "0.098" } }, "idiom" : "universal" @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.129", - "green" : "0.129", - "red" : "0.129" + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" } }, "idiom" : "universal" diff --git a/Theme/Theme/Assets.xcassets/appLogoLight.imageset/Contents.json b/Theme/Theme/Assets.xcassets/appLogoLight.imageset/Contents.json new file mode 100644 index 000000000..0c3881647 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/appLogoLight.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "filename" : "appLogoWhite.svg", + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/appLogoLight.imageset/appLogoWhite.svg b/Theme/Theme/Assets.xcassets/appLogoLight.imageset/appLogoWhite.svg new file mode 100644 index 000000000..5a2d136db --- /dev/null +++ b/Theme/Theme/Assets.xcassets/appLogoLight.imageset/appLogoWhite.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Theme/Theme/SwiftGen/ThemeAssets.swift b/Theme/Theme/SwiftGen/ThemeAssets.swift index f5fedb009..7eb0fcb2e 100644 --- a/Theme/Theme/SwiftGen/ThemeAssets.swift +++ b/Theme/Theme/SwiftGen/ThemeAssets.swift @@ -27,6 +27,7 @@ public enum ThemeAssets { public static let authBackground = ImageAsset(name: "authBackground") public static let accentButtonColor = ColorAsset(name: "AccentButtonColor") public static let accentColor = ColorAsset(name: "AccentColor") + public static let accentXColor = ColorAsset(name: "AccentXColor") public static let alert = ColorAsset(name: "Alert") public static let avatarStroke = ColorAsset(name: "AvatarStroke") public static let background = ColorAsset(name: "Background") @@ -37,19 +38,23 @@ public enum ThemeAssets { public static let commentCellBackground = ColorAsset(name: "CommentCellBackground") public static let loginBackground = ColorAsset(name: "LoginBackground") public static let loginNavigationText = ColorAsset(name: "LoginNavigationText") + public static let secondardButtonBorderColor = ColorAsset(name: "SecondardButtonBorderColor") + public static let secondardButtonTextColor = ColorAsset(name: "SecondardButtonTextColor") public static let shadowColor = ColorAsset(name: "ShadowColor") public static let snackbarErrorColor = ColorAsset(name: "SnackbarErrorColor") - public static let snackbarErrorTextColor = ColorAsset(name: "SnackbarErrorTextColor") public static let snackbarInfoAlert = ColorAsset(name: "SnackbarInfoAlert") + public static let snackbarWarningColor = ColorAsset(name: "SnackbarWarningColor") public static let splashBackground = ColorAsset(name: "SplashBackground") public static let styledButtonBackground = ColorAsset(name: "StyledButtonBackground") public static let styledButtonText = ColorAsset(name: "StyledButtonText") public static let textPrimary = ColorAsset(name: "TextPrimary") public static let textSecondary = ColorAsset(name: "TextSecondary") + public static let textSecondaryLight = ColorAsset(name: "TextSecondaryLight") public static let textInputBackground = ColorAsset(name: "TextInputBackground") public static let textInputStroke = ColorAsset(name: "TextInputStroke") public static let textInputUnfocusedBackground = ColorAsset(name: "TextInputUnfocusedBackground") public static let textInputUnfocusedStroke = ColorAsset(name: "TextInputUnfocusedStroke") + public static let navigationBarTintColor = ColorAsset(name: "navigationBarTintColor") public static let warning = ColorAsset(name: "warning") public static let white = ColorAsset(name: "white") public static let datesSectionBackground = ColorAsset(name: "DatesSectionBackground") @@ -64,6 +69,7 @@ public enum ThemeAssets { public static let progressSkip = ColorAsset(name: "ProgressSkip") public static let selectedAndDone = ColorAsset(name: "SelectedAndDone") public static let appLogo = ImageAsset(name: "appLogo") + public static let appLogoLight = ImageAsset(name: "appLogoLight") } // swiftlint:enable identifier_name line_length nesting type_body_length type_name diff --git a/Theme/Theme/Theme.swift b/Theme/Theme/Theme.swift index 63937d812..d2cbbeae2 100644 --- a/Theme/Theme/Theme.swift +++ b/Theme/Theme/Theme.swift @@ -14,6 +14,7 @@ public struct Theme { public struct Colors { public private(set) static var accentColor = ThemeAssets.accentColor.swiftUIColor + public private(set) static var accentXColor = ThemeAssets.accentXColor.swiftUIColor public private(set) static var accentButtonColor = ThemeAssets.accentButtonColor.swiftUIColor public private(set) static var alert = ThemeAssets.alert.swiftUIColor public private(set) static var avatarStroke = ThemeAssets.avatarStroke.swiftUIColor @@ -31,12 +32,12 @@ public struct Theme { public private(set) static var upcomingTimelineColor = ThemeAssets.upcomingTimelineColor.swiftUIColor public private(set) static var shadowColor = ThemeAssets.shadowColor.swiftUIColor public private(set) static var snackbarErrorColor = ThemeAssets.snackbarErrorColor.swiftUIColor - public private(set) static var snackbarErrorTextColor = ThemeAssets.snackbarErrorTextColor.swiftUIColor + public private(set) static var snackbarWarningColor = ThemeAssets.snackbarWarningColor.swiftUIColor public private(set) static var snackbarInfoAlert = ThemeAssets.snackbarInfoAlert.swiftUIColor - public private(set) static var styledButtonBackground = ThemeAssets.styledButtonBackground.swiftUIColor public private(set) static var styledButtonText = ThemeAssets.styledButtonText.swiftUIColor public private(set) static var textPrimary = ThemeAssets.textPrimary.swiftUIColor public private(set) static var textSecondary = ThemeAssets.textSecondary.swiftUIColor + public private(set) static var textSecondaryLight = ThemeAssets.textSecondaryLight.swiftUIColor public private(set) static var textInputBackground = ThemeAssets.textInputBackground.swiftUIColor public private(set) static var textInputStroke = ThemeAssets.textInputStroke.swiftUIColor public private(set) static var textInputUnfocusedBackground = ThemeAssets.textInputUnfocusedBackground.swiftUIColor @@ -50,9 +51,13 @@ public struct Theme { public private(set) static var loginNavigationText = ThemeAssets.loginNavigationText.swiftUIColor public private(set) static var datesSectionBackground = ThemeAssets.datesSectionBackground.swiftUIColor public private(set) static var datesSectionStroke = ThemeAssets.datesSectionStroke.swiftUIColor + public private(set) static var navigationBarTintColor = ThemeAssets.navigationBarTintColor.swiftUIColor + public private(set) static var secondardButtonBorderColor = ThemeAssets.secondardButtonBorderColor.swiftUIColor + public private(set) static var secondardButtonTextColor = ThemeAssets.secondardButtonTextColor.swiftUIColor public static func update( accentColor: Color = ThemeAssets.accentColor.swiftUIColor, + accentXColor: Color = ThemeAssets.accentXColor.swiftUIColor, alert: Color = ThemeAssets.alert.swiftUIColor, avatarStroke: Color = ThemeAssets.avatarStroke.swiftUIColor, background: Color = ThemeAssets.background.swiftUIColor, @@ -68,12 +73,11 @@ public struct Theme { upcomingTimelineColor: Color = ThemeAssets.upcomingTimelineColor.swiftUIColor, shadowColor: Color = ThemeAssets.shadowColor.swiftUIColor, snackbarErrorColor: Color = ThemeAssets.snackbarErrorColor.swiftUIColor, - snackbarErrorTextColor: Color = ThemeAssets.snackbarErrorTextColor.swiftUIColor, snackbarInfoAlert: Color = ThemeAssets.snackbarInfoAlert.swiftUIColor, - styledButtonBackground: Color = ThemeAssets.styledButtonBackground.swiftUIColor, styledButtonText: Color = ThemeAssets.styledButtonText.swiftUIColor, textPrimary: Color = ThemeAssets.textPrimary.swiftUIColor, textSecondary: Color = ThemeAssets.textSecondary.swiftUIColor, + textSecondaryLight: Color = ThemeAssets.textSecondaryLight.swiftUIColor, textInputBackground: Color = ThemeAssets.textInputBackground.swiftUIColor, textInputStroke: Color = ThemeAssets.textInputStroke.swiftUIColor, textInputUnfocusedBackground: Color = ThemeAssets.textInputUnfocusedBackground.swiftUIColor, @@ -84,9 +88,13 @@ public struct Theme { progressDone: Color = ThemeAssets.progressDone.swiftUIColor, progressSkip: Color = ThemeAssets.progressSkip.swiftUIColor, datesSectionBackground: Color = ThemeAssets.datesSectionBackground.swiftUIColor, - datesSectionStroke: Color = ThemeAssets.datesSectionStroke.swiftUIColor + datesSectionStroke: Color = ThemeAssets.datesSectionStroke.swiftUIColor, + navigationBarTintColor: Color = ThemeAssets.navigationBarTintColor.swiftUIColor, + secondardButtonBorderColor: Color = ThemeAssets.secondardButtonBorderColor.swiftUIColor, + secondardButtonTextColor: Color = ThemeAssets.secondardButtonTextColor.swiftUIColor ) { self.accentColor = accentColor + self.accentXColor = accentXColor self.alert = alert self.avatarStroke = avatarStroke self.background = background @@ -102,12 +110,11 @@ public struct Theme { self.upcomingTimelineColor = upcomingTimelineColor self.shadowColor = shadowColor self.snackbarErrorColor = snackbarErrorColor - self.snackbarErrorTextColor = snackbarErrorTextColor self.snackbarInfoAlert = snackbarInfoAlert - self.styledButtonBackground = styledButtonBackground self.styledButtonText = styledButtonText self.textPrimary = textPrimary self.textSecondary = textSecondary + self.textSecondaryLight = textSecondaryLight self.textInputBackground = textInputBackground self.textInputStroke = textInputStroke self.textInputUnfocusedBackground = textInputUnfocusedBackground @@ -119,6 +126,9 @@ public struct Theme { self.progressSkip = progressSkip self.datesSectionBackground = datesSectionBackground self.datesSectionStroke = datesSectionStroke + self.navigationBarTintColor = navigationBarTintColor + self.secondardButtonBorderColor = secondardButtonBorderColor + self.secondardButtonTextColor = secondardButtonTextColor } } @@ -126,13 +136,19 @@ public struct Theme { public struct UIColors { public private(set) static var textPrimary = ThemeAssets.textPrimary.color public private(set) static var accentColor = ThemeAssets.accentColor.color + public private(set) static var accentXColor = ThemeAssets.accentXColor.color + public private(set) static var navigationBarTintColor = ThemeAssets.navigationBarTintColor.color public static func update( textPrimary: UIColor = ThemeAssets.textPrimary.color, - accentColor: UIColor = ThemeAssets.accentColor.color + accentColor: UIColor = ThemeAssets.accentColor.color, + accentXColor: UIColor = ThemeAssets.accentXColor.color, + navigationBarTintColor: UIColor = ThemeAssets.navigationBarTintColor.color ) { self.textPrimary = textPrimary self.accentColor = accentColor + self.accentXColor = accentXColor + self.navigationBarTintColor = navigationBarTintColor } } diff --git a/WhatsNew/WhatsNew/Presentation/Elements/PageControl.swift b/WhatsNew/WhatsNew/Presentation/Elements/PageControl.swift index 290369d3f..92ce5a1ae 100644 --- a/WhatsNew/WhatsNew/Presentation/Elements/PageControl.swift +++ b/WhatsNew/WhatsNew/Presentation/Elements/PageControl.swift @@ -18,7 +18,7 @@ struct PageControl: View { 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) + .foregroundColor(page == currentPage ? Theme.Colors.accentXColor : Theme.Colors.textInputStroke) } } } diff --git a/WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift b/WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift index 828fcbf52..e6bcb19c2 100644 --- a/WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift +++ b/WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift @@ -23,12 +23,12 @@ struct WhatsNewNavigationButton: View { if type == .previous { CoreAssets.arrowLeft.swiftUIImage .renderingMode(.template) - .foregroundColor(Theme.Colors.accentColor) + .foregroundColor(Theme.Colors.secondardButtonTextColor) } Text(type == .previous ? WhatsNewLocalization.buttonPrevious : (type == .next ? WhatsNewLocalization.buttonNext : WhatsNewLocalization.buttonDone )) - .foregroundColor(type == .previous ? Theme.Colors.accentColor : Theme.Colors.white) + .foregroundColor(type == .previous ? Theme.Colors.secondardButtonTextColor : Theme.Colors.white) .font(Theme.Fonts.labelLarge) if type == .next { @@ -60,7 +60,7 @@ struct WhatsNewNavigationButton: View { .overlay( Theme.Shapes.buttonShape .stroke(type == .previous - ? Theme.Colors.accentButtonColor + ? Theme.Colors.secondardButtonBorderColor : Theme.Colors.background, lineWidth: 1) ) .onTapGesture { action() } diff --git a/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift b/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift index 8fe29844d..782315d43 100644 --- a/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift +++ b/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift @@ -145,7 +145,7 @@ public struct WhatsNewView: View { router.showMainOrWhatsNewScreen(sourceScreen: viewModel.sourceScreen) }, label: { Image(systemName: "xmark") - .foregroundColor(Theme.Colors.accentColor) + .foregroundColor(Theme.Colors.accentXColor) }) .accessibilityIdentifier("close_button") }) From 35e7cbe63ac2c43c393e74b370ccd8ac23c9562b Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Mon, 12 Feb 2024 10:17:09 +0100 Subject: [PATCH 009/136] Abstract layer for Push Notifications and Deep Linking (#263) * chore: added abstract layer for push notifications * chore: abstract layer for deep linking * chore: renamed some func * chore: small refactor * chore: added RawStringExtractable to configs * chore: address review feedback * chore: address review feedback --- Core/Core.xcodeproj/project.pbxproj | 8 ++ .../Configuration/Config/BranchConfig.swift | 31 ++++++ .../Configuration/Config/BrazeConfig.swift | 32 ++++++ Core/Core/Configuration/Config/Config.swift | 2 + .../CoreTests/Configuration/ConfigTests.swift | 21 ++++ OpenEdX.xcodeproj/project.pbxproj | 104 +++++++++++++++++- OpenEdX/AppDelegate.swift | 76 ++++++++++--- OpenEdX/DI/AppAssembly.swift | 12 ++ .../AnalyticsManager}/AnalyticsManager.swift | 0 .../MainScreenAnalytics.swift | 0 .../DeepLinkManager/DeepLinkManager.swift | 70 ++++++++++++ .../DeepLinkManager/Link/DeepLink.swift | 18 +++ .../DeepLinkManager/Link/PushLink.swift | 30 +++++ .../Services/BranchService.swift | 25 +++++ .../Listeners/BrazeListener.swift | 13 +++ .../Listeners/FCMListener.swift | 13 +++ .../Providers/BrazeProvider.swift | 18 +++ .../Providers/FCMProvider.swift | 18 +++ .../PushNotificationsManager.swift | 97 ++++++++++++++++ 19 files changed, 571 insertions(+), 17 deletions(-) create mode 100644 Core/Core/Configuration/Config/BranchConfig.swift create mode 100644 Core/Core/Configuration/Config/BrazeConfig.swift rename OpenEdX/{ => Managers/AnalyticsManager}/AnalyticsManager.swift (100%) rename OpenEdX/{ => Managers/AnalyticsManager}/MainScreenAnalytics.swift (100%) create mode 100644 OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift create mode 100644 OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift create mode 100644 OpenEdX/Managers/DeepLinkManager/Link/PushLink.swift create mode 100644 OpenEdX/Managers/DeepLinkManager/Services/BranchService.swift create mode 100644 OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift create mode 100644 OpenEdX/Managers/PushNotificationsManager/Listeners/FCMListener.swift create mode 100644 OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift create mode 100644 OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift create mode 100644 OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index d788284c8..bde6eadac 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -126,6 +126,8 @@ 07E0939F2B308D2800F1E4B2 /* Data_Certificate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E0939E2B308D2800F1E4B2 /* Data_Certificate.swift */; }; 141F1D302B7328D4009E81EB /* WebviewCookiesUpdateProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 141F1D2F2B7328D4009E81EB /* WebviewCookiesUpdateProtocol.swift */; }; A53A32352B233DEC005FE38A /* ThemeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A32342B233DEC005FE38A /* ThemeConfig.swift */; }; + A595689B2B6173DF00ED4F90 /* BranchConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A595689A2B6173DF00ED4F90 /* BranchConfig.swift */; }; + A5F4E7B52B61544A00ACD166 /* BrazeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */; }; BA30427F2B20B320009B64B7 /* SocialAuthError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA30427D2B20B299009B64B7 /* SocialAuthError.swift */; }; BA4AFB442B6A5AF100A21367 /* CheckBoxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA4AFB432B6A5AF100A21367 /* CheckBoxView.swift */; }; BA4AFB422B5A7A0900A21367 /* VideoDownloadQualityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA4AFB412B5A7A0900A21367 /* VideoDownloadQualityView.swift */; }; @@ -300,6 +302,8 @@ 60153262DBC2F9E660D7E11B /* Pods-App-Core.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.release.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.release.xcconfig"; sourceTree = ""; }; 9D5B06CAA99EA5CD49CBE2BB /* Pods-App-Core.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debugdev.xcconfig"; sourceTree = ""; }; A53A32342B233DEC005FE38A /* ThemeConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ThemeConfig.swift; sourceTree = ""; }; + A595689A2B6173DF00ED4F90 /* BranchConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BranchConfig.swift; sourceTree = ""; }; + A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrazeConfig.swift; sourceTree = ""; }; BA30427D2B20B299009B64B7 /* SocialAuthError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SocialAuthError.swift; sourceTree = ""; }; BA4AFB432B6A5AF100A21367 /* CheckBoxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckBoxView.swift; sourceTree = ""; }; BA4AFB412B5A7A0900A21367 /* VideoDownloadQualityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadQualityView.swift; sourceTree = ""; }; @@ -772,6 +776,8 @@ 0604C9A92B22FACF00AD5DBF /* UIComponentsConfig.swift */, 0727876F28D23411002E9142 /* Config.swift */, DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */, + A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */, + A595689A2B6173DF00ED4F90 /* BranchConfig.swift */, DBF6F2492B0380E00098414B /* FeaturesConfig.swift */, DBF6F2452B01DAFE0098414B /* AgreementConfig.swift */, BAFB99812B0E2354007D09F9 /* FacebookConfig.swift */, @@ -1055,6 +1061,7 @@ 021D925728DCF12900ACC565 /* AlertView.swift in Sources */, 027BD3A82909474200392132 /* KeyboardAvoidingViewController.swift in Sources */, 02E93F852AEBAEBC006C4750 /* AppReviewViewModel.swift in Sources */, + A595689B2B6173DF00ED4F90 /* BranchConfig.swift in Sources */, 0770DE2528D08FBA006D8A5D /* CoreStorage.swift in Sources */, BA8FA6612AD5974300EA029A /* AppleAuthProvider.swift in Sources */, BA8FA6702AD59EA300EA029A /* MicrosoftAuthProvider.swift in Sources */, @@ -1082,6 +1089,7 @@ DBF6F2412B014ADA0098414B /* FirebaseConfig.swift in Sources */, 072787B628D37A0E002E9142 /* Validator.swift in Sources */, 0236961D28F9A2D200EEF206 /* Data_AuthResponse.swift in Sources */, + A5F4E7B52B61544A00ACD166 /* BrazeConfig.swift in Sources */, 02AFCC182AEFDB24000360F0 /* ThirdPartyMailClient.swift in Sources */, 0233D5712AF13EC800BAC8BD /* SelectMailClientView.swift in Sources */, BAFB99842B0E282E007D09F9 /* MicrosoftConfig.swift in Sources */, diff --git a/Core/Core/Configuration/Config/BranchConfig.swift b/Core/Core/Configuration/Config/BranchConfig.swift new file mode 100644 index 000000000..43faec57f --- /dev/null +++ b/Core/Core/Configuration/Config/BranchConfig.swift @@ -0,0 +1,31 @@ +// +// BranchConfig.swift +// Core +// +// Created by Anton Yarmolenka on 24/01/2024. +// + +import Foundation + +private enum BranchKeys: String, RawStringExtractable { + case enabled = "ENABLED" + case key = "KEY" +} + +public final class BranchConfig: NSObject { + public var enabled: Bool = false + public var key: String? + + init(dictionary: [String: AnyObject]) { + super.init() + enabled = dictionary[BranchKeys.enabled] as? Bool == true + key = dictionary[BranchKeys.key] as? String + } +} + +private let branchKey = "BRANCH" +extension Config { + public var branch: BranchConfig { + BranchConfig(dictionary: self[branchKey] as? [String: AnyObject] ?? [:]) + } +} diff --git a/Core/Core/Configuration/Config/BrazeConfig.swift b/Core/Core/Configuration/Config/BrazeConfig.swift new file mode 100644 index 000000000..0cbc10db8 --- /dev/null +++ b/Core/Core/Configuration/Config/BrazeConfig.swift @@ -0,0 +1,32 @@ +// +// BrazeConfig.swift +// Core +// +// Created by Anton Yarmolenka on 24/01/2024. +// + +import Foundation + +private enum BrazeKeys: String, RawStringExtractable { + case enabled = "ENABLED" + case pushNotificationsEnabled = "PUSH_NOTIFICATIONS_ENABLED" +} + +public final class BrazeConfig: NSObject { + public var enabled: Bool = false + public var pushNotificationsEnabled: Bool = false + + init(dictionary: [String: AnyObject]) { + super.init() + enabled = dictionary[BrazeKeys.enabled] as? Bool == true + let pushNotificationsEnabled = dictionary[BrazeKeys.pushNotificationsEnabled] as? Bool ?? false + self.pushNotificationsEnabled = enabled && pushNotificationsEnabled + } +} + +private let brazeKey = "BRAZE" +extension Config { + public var braze: BrazeConfig { + BrazeConfig(dictionary: self[brazeKey] as? [String: AnyObject] ?? [:]) + } +} diff --git a/Core/Core/Configuration/Config/Config.swift b/Core/Core/Configuration/Config/Config.swift index 481f67b9c..86dcb1d5f 100644 --- a/Core/Core/Configuration/Config/Config.swift +++ b/Core/Core/Configuration/Config/Config.swift @@ -25,6 +25,8 @@ public protocol ConfigProtocol { var theme: ThemeConfig { get } var uiComponents: UIComponentsConfig { get } var discovery: DiscoveryConfig { get } + var braze: BrazeConfig { get } + var branch: BranchConfig { get } var program: DiscoveryConfig { get } var URIScheme: String { get } } diff --git a/Core/CoreTests/Configuration/ConfigTests.swift b/Core/CoreTests/Configuration/ConfigTests.swift index 32638e484..21ab17ddf 100644 --- a/Core/CoreTests/Configuration/ConfigTests.swift +++ b/Core/CoreTests/Configuration/ConfigTests.swift @@ -52,6 +52,14 @@ class ConfigTests: XCTestCase { ], "APPLE_SIGNIN": [ "ENABLED": true + ], + "BRAZE": [ + "ENABLED": true, + "PUSH_NOTIFICATIONS_ENABLED": true + ], + "BRANCH": [ + "ENABLED": true, + "KEY": "testBranchKey" ] ] @@ -115,4 +123,17 @@ class ConfigTests: XCTestCase { XCTAssertTrue(config.appleSignIn.enabled) } + + func testBrazeConfigInitialization() { + let config = Config(properties: properties) + + XCTAssertTrue(config.braze.pushNotificationsEnabled) + } + + func testBranchConfigInitialization() { + let config = Config(properties: properties) + + XCTAssertTrue(config.branch.enabled) + XCTAssertEqual(config.branch.key, "testBranchKey") + } } diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 072473af5..85704b35b 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -45,6 +45,15 @@ 07D5DA3E28D075AB00752FD9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 07D5DA3D28D075AB00752FD9 /* Assets.xcassets */; }; 07D5DA4128D075AB00752FD9 /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 07D5DA3F28D075AB00752FD9 /* LaunchScreen.storyboard */; }; 95C140F3BDF778364986E83B /* Pods_App_OpenEdX.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F138C15C3A2515F8F94DAA8B /* Pods_App_OpenEdX.framework */; }; + A500668B2B613ED10024680B /* PushNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500668A2B613ED10024680B /* PushNotificationsManager.swift */; }; + A500668D2B6143000024680B /* FCMProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500668C2B6143000024680B /* FCMProvider.swift */; }; + A50066912B61467B0024680B /* BrazeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50066902B61467B0024680B /* BrazeProvider.swift */; }; + A50066932B614DCD0024680B /* FCMListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50066922B614DCD0024680B /* FCMListener.swift */; }; + A50066952B614DEF0024680B /* BrazeListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50066942B614DEF0024680B /* BrazeListener.swift */; }; + A59568952B61630500ED4F90 /* DeepLinkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568942B61630500ED4F90 /* DeepLinkManager.swift */; }; + A59568972B61653700ED4F90 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568962B61653700ED4F90 /* DeepLink.swift */; }; + A59568992B616D9400ED4F90 /* PushLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568982B616D9400ED4F90 /* PushLink.swift */; }; + A59585AF2B62A07100A35A20 /* BranchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59585AE2B62A07100A35A20 /* BranchService.swift */; }; BA3042792B1F7147009B64B7 /* MSAL in Frameworks */ = {isa = PBXBuildFile; productRef = BA3042782B1F7147009B64B7 /* MSAL */; }; E0D6E6A32B1626B10089F9C9 /* Theme.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0D6E6A22B1626B10089F9C9 /* Theme.framework */; }; E0D6E6A42B1626D60089F9C9 /* Theme.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E0D6E6A22B1626B10089F9C9 /* Theme.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -115,6 +124,15 @@ 6F54C19C823A769E18923FA8 /* Pods-App-OpenEdX.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugstage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugstage.xcconfig"; sourceTree = ""; }; 8284179FC05AEE2591573E20 /* Pods-App-OpenEdX.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugdev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugdev.xcconfig"; sourceTree = ""; }; A24D6A8E1BC4DF46AD68904C /* Pods-App-OpenEdX.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releaseprod.xcconfig"; sourceTree = ""; }; + A500668A2B613ED10024680B /* PushNotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationsManager.swift; sourceTree = ""; }; + A500668C2B6143000024680B /* FCMProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FCMProvider.swift; sourceTree = ""; }; + A50066902B61467B0024680B /* BrazeProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrazeProvider.swift; sourceTree = ""; }; + A50066922B614DCD0024680B /* FCMListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FCMListener.swift; sourceTree = ""; }; + A50066942B614DEF0024680B /* BrazeListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrazeListener.swift; sourceTree = ""; }; + A59568942B61630500ED4F90 /* DeepLinkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkManager.swift; sourceTree = ""; }; + A59568962B61653700ED4F90 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; + A59568982B616D9400ED4F90 /* PushLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushLink.swift; sourceTree = ""; }; + A59585AE2B62A07100A35A20 /* BranchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BranchService.swift; sourceTree = ""; }; A89AD827F52CF6A6B903606E /* Pods-App-OpenEdX.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasestage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasestage.xcconfig"; sourceTree = ""; }; BB08ACD2CCA33D6DDDDD31B4 /* Pods-App-OpenEdX.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasedev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasedev.xcconfig"; sourceTree = ""; }; E0D6E6A22B1626B10089F9C9 /* Theme.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Theme.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -200,8 +218,7 @@ 07D5DA3428D075AA00752FD9 /* AppDelegate.swift */, 0770DE1628D080A1006D8A5D /* RouteController.swift */, 0770DE1F28D0858A006D8A5D /* Router.swift */, - 0298DF2F2A4EF7230023A257 /* AnalyticsManager.swift */, - 02F175302A4DA95B0019CD70 /* MainScreenAnalytics.swift */, + A50066882B613E800024680B /* Managers */, 0293A2012A6FC9E30090A336 /* Data */, 0727878C28D347B2002E9142 /* View */, 0770DE1A28D084BC006D8A5D /* DI */, @@ -248,6 +265,80 @@ path = Pods; sourceTree = ""; }; + A50066872B613E4B0024680B /* PushNotificationsManager */ = { + isa = PBXGroup; + children = ( + A500668A2B613ED10024680B /* PushNotificationsManager.swift */, + A50066962B614F0C0024680B /* Providers */, + A50066972B614F2B0024680B /* Listeners */, + ); + path = PushNotificationsManager; + sourceTree = ""; + }; + A50066882B613E800024680B /* Managers */ = { + isa = PBXGroup; + children = ( + A59568932B6162E400ED4F90 /* DeepLinkManager */, + A50066872B613E4B0024680B /* PushNotificationsManager */, + A50066892B613E990024680B /* AnalyticsManager */, + ); + path = Managers; + sourceTree = ""; + }; + A50066892B613E990024680B /* AnalyticsManager */ = { + isa = PBXGroup; + children = ( + 0298DF2F2A4EF7230023A257 /* AnalyticsManager.swift */, + 02F175302A4DA95B0019CD70 /* MainScreenAnalytics.swift */, + ); + path = AnalyticsManager; + sourceTree = ""; + }; + A50066962B614F0C0024680B /* Providers */ = { + isa = PBXGroup; + children = ( + A500668C2B6143000024680B /* FCMProvider.swift */, + A50066902B61467B0024680B /* BrazeProvider.swift */, + ); + path = Providers; + sourceTree = ""; + }; + A50066972B614F2B0024680B /* Listeners */ = { + isa = PBXGroup; + children = ( + A50066922B614DCD0024680B /* FCMListener.swift */, + A50066942B614DEF0024680B /* BrazeListener.swift */, + ); + path = Listeners; + sourceTree = ""; + }; + A59568932B6162E400ED4F90 /* DeepLinkManager */ = { + isa = PBXGroup; + children = ( + A59568942B61630500ED4F90 /* DeepLinkManager.swift */, + A5F46FD02B692B140003EEEF /* Services */, + A59585AD2B62677B00A35A20 /* Link */, + ); + path = DeepLinkManager; + sourceTree = ""; + }; + A59585AD2B62677B00A35A20 /* Link */ = { + isa = PBXGroup; + children = ( + A59568962B61653700ED4F90 /* DeepLink.swift */, + A59568982B616D9400ED4F90 /* PushLink.swift */, + ); + path = Link; + sourceTree = ""; + }; + A5F46FD02B692B140003EEEF /* Services */ = { + isa = PBXGroup; + children = ( + A59585AE2B62A07100A35A20 /* BranchService.swift */, + ); + path = Services; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -413,9 +504,12 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + A500668B2B613ED10024680B /* PushNotificationsManager.swift in Sources */, 0293A2052A6FCD430090A336 /* CoursePersistence.swift in Sources */, 020CA5D92AA0A25300970AAF /* AppStorage.swift in Sources */, 0298DF302A4EF7230023A257 /* AnalyticsManager.swift in Sources */, + A59585AF2B62A07100A35A20 /* BranchService.swift in Sources */, + A50066912B61467B0024680B /* BrazeProvider.swift in Sources */, 0293A2072A6FCDA30090A336 /* DiscoveryPersistence.swift in Sources */, 07D5DA3528D075AA00752FD9 /* AppDelegate.swift in Sources */, 02F175312A4DA95B0019CD70 /* MainScreenAnalytics.swift in Sources */, @@ -423,12 +517,18 @@ 0770DE5028D0A707006D8A5D /* NetworkAssembly.swift in Sources */, 0293A2032A6FCA590090A336 /* CorePersistence.swift in Sources */, 0770DE1E28D084E8006D8A5D /* AppAssembly.swift in Sources */, + A50066932B614DCD0024680B /* FCMListener.swift in Sources */, + A500668D2B6143000024680B /* FCMProvider.swift in Sources */, 025AD4AC2A6FB95C00AB8FA7 /* DatabaseManager.swift in Sources */, 024E69202AEFC3FB00FA0B59 /* MainScreenViewModel.swift in Sources */, 0770DE2028D0858A006D8A5D /* Router.swift in Sources */, 0293A2092A6FCDE50090A336 /* DashboardPersistence.swift in Sources */, 0770DE1728D080A1006D8A5D /* RouteController.swift in Sources */, + A59568952B61630500ED4F90 /* DeepLinkManager.swift in Sources */, + A50066952B614DEF0024680B /* BrazeListener.swift in Sources */, 071009C928D1DB3F00344290 /* ScreenAssembly.swift in Sources */, + A59568972B61653700ED4F90 /* DeepLink.swift in Sources */, + A59568992B616D9400ED4F90 /* PushLink.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index adca92809..e10373d43 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -48,6 +48,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { didFinishLaunchingWithOptions: launchOptions ) } + configureDeepLinkServices(launchOptions: launchOptions) } Theme.Fonts.registerFonts() @@ -62,6 +63,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { object: nil ) + if let pushManager = Container.shared.resolve(PushNotificationsManager.self) { + pushManager.performRegistration() + } + return true } @@ -69,25 +74,38 @@ class AppDelegate: UIResponder, UIApplicationDelegate { _ app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] = [:] ) -> Bool { - if let config = Container.shared.resolve(ConfigProtocol.self) { - if config.facebook.enabled { - ApplicationDelegate.shared.application( - app, - open: url, - sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String, - annotation: options[UIApplication.OpenURLOptionsKey.annotation] - ) + guard let config = Container.shared.resolve(ConfigProtocol.self) else { return false } + + if let deepLinkManager = Container.shared.resolve(DeepLinkManager.self), + deepLinkManager.anyServiceEnabled { + if deepLinkManager.handledURLWith(app: app, open: url, options: options) { + return true } + } - if config.google.enabled { - return GIDSignIn.sharedInstance.handle(url) + if config.facebook.enabled { + if ApplicationDelegate.shared.application( + app, + open: url, + sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String, + annotation: options[UIApplication.OpenURLOptionsKey.annotation] + ) { + return true } + } - if config.microsoft.enabled { - return MSALPublicClientApplication.handleMSALResponse( - url, - sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String - ) + if config.google.enabled { + if GIDSignIn.sharedInstance.handle(url) { + return true + } + } + + if config.microsoft.enabled { + if MSALPublicClientApplication.handleMSALResponse( + url, + sourceApplication: options[UIApplication.OpenURLOptionsKey.sourceApplication] as? String + ) { + return true } } @@ -125,4 +143,32 @@ class AppDelegate: UIResponder, UIApplicationDelegate { window?.rootViewController = RouteController() } + // Push Notifications + func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { + guard let pushManager = Container.shared.resolve(PushNotificationsManager.self) else { return } + pushManager.didRegisterForRemoteNotificationsWithDeviceToken(deviceToken: deviceToken) + } + func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) { + guard let pushManager = Container.shared.resolve(PushNotificationsManager.self) else { return } + pushManager.didFailToRegisterForRemoteNotificationsWithError(error: error) + } + func application( + _ application: UIApplication, + didReceiveRemoteNotification userInfo: [AnyHashable: Any], + fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void + ) { + guard let pushManager = Container.shared.resolve(PushNotificationsManager.self) + else { + completionHandler(.newData) + return + } + pushManager.didReceiveRemoteNotification(userInfo: userInfo) + completionHandler(.newData) + } + + // Deep link + func configureDeepLinkServices(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { + guard let deepLinkManager = Container.shared.resolve(DeepLinkManager.self) else { return } + deepLinkManager.configureDeepLinkService(launchOptions: launchOptions) + } } diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 8015abff3..8a98db84c 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -161,6 +161,18 @@ class AppAssembly: Assembly { container.register(Validator.self) { _ in Validator() }.inObjectScope(.container) + + container.register(PushNotificationsManager.self) { r in + PushNotificationsManager( + config: r.resolve(ConfigProtocol.self)! + ) + }.inObjectScope(.container) + + container.register(DeepLinkManager.self) { r in + DeepLinkManager( + config: r.resolve(ConfigProtocol.self)! + ) + }.inObjectScope(.container) } } // swiftlint:enable function_body_length diff --git a/OpenEdX/AnalyticsManager.swift b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift similarity index 100% rename from OpenEdX/AnalyticsManager.swift rename to OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift diff --git a/OpenEdX/MainScreenAnalytics.swift b/OpenEdX/Managers/AnalyticsManager/MainScreenAnalytics.swift similarity index 100% rename from OpenEdX/MainScreenAnalytics.swift rename to OpenEdX/Managers/AnalyticsManager/MainScreenAnalytics.swift diff --git a/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift b/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift new file mode 100644 index 000000000..157bf2bb7 --- /dev/null +++ b/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift @@ -0,0 +1,70 @@ +// +// DeepLinkManager.swift +// OpenEdX +// +// Created by Anton Yarmolenka on 24/01/2024. +// + +import Foundation +import Core +import UIKit + +public protocol DeepLinkService { + func configureWith(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) + func handledURLWith(app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any]) -> Bool +} + +class DeepLinkManager { + private var services: [DeepLinkService] = [] + + // Init manager + public init(config: ConfigProtocol) { + services = servicesFor(config: config) + } + + private func servicesFor(config: ConfigProtocol) -> [DeepLinkService] { + var deepServices: [DeepLinkService] = [] + // init deep link services + if config.branch.enabled { + deepServices.append(BranchService()) + } + return deepServices + } + + // check if any service is added (means enabled) + var anyServiceEnabled: Bool { + services.count > 0 + } + + // Configure services + func configureDeepLinkService(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { + for service in services { + service.configureWith(launchOptions: launchOptions) + } + } + + // Handle open url + func handledURLWith( + app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] = [:] + ) -> Bool { + for service in services where service.handledURLWith(app: app, open: url, options: options) { + return true + } + return false + } + + // This method do redirect with link from push notification + func processLinkFromNotification(_ link: PushLink) { + // redirect if possible + } + + // This method process the deep link with response parameters + func processDeepLink(with params: [String: Any]) { + if anyServiceEnabled { + // redirect if possible + } + } + +} diff --git a/OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift b/OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift new file mode 100644 index 000000000..3df8a21e6 --- /dev/null +++ b/OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift @@ -0,0 +1,18 @@ +// +// DeepLink.swift +// OpenEdX +// +// Created by Anton Yarmolenka on 24/01/2024. +// + +import Foundation + +enum DeepLinkType: String { + case none +} + +public class DeepLink { + init(dictionary: [String: Any]) { + + } +} diff --git a/OpenEdX/Managers/DeepLinkManager/Link/PushLink.swift b/OpenEdX/Managers/DeepLinkManager/Link/PushLink.swift new file mode 100644 index 000000000..262bda304 --- /dev/null +++ b/OpenEdX/Managers/DeepLinkManager/Link/PushLink.swift @@ -0,0 +1,30 @@ +// +// PushLink.swift +// OpenEdX +// +// Created by Anton Yarmolenka on 24/01/2024. +// + +import Foundation + +enum DataKeys: String { + case title + case body + case aps + case alert +} + +// This link will have information of course and screen type which will be use to route on particular screen. +public class PushLink: DeepLink { + let title: String? + let body: String? + + override init(dictionary: [String: Any]) { + let aps = dictionary[DataKeys.aps.rawValue] as? [String: Any] + let alert = aps?[DataKeys.alert.rawValue] as? [String: Any] + title = alert?[DataKeys.title.rawValue] as? String + body = alert?[DataKeys.body.rawValue] as? String + + super.init(dictionary: dictionary) + } +} diff --git a/OpenEdX/Managers/DeepLinkManager/Services/BranchService.swift b/OpenEdX/Managers/DeepLinkManager/Services/BranchService.swift new file mode 100644 index 000000000..07a402a60 --- /dev/null +++ b/OpenEdX/Managers/DeepLinkManager/Services/BranchService.swift @@ -0,0 +1,25 @@ +// +// BranchService.swift +// OpenEdX +// +// Created by Anton Yarmolenka on 25/01/2024. +// + +import Foundation +import UIKit + +class BranchService: DeepLinkService { + // configure service + func configureWith(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { + + } + + // handle url and call DeepLinkanager.processDeepLink() with params + func handledURLWith( + app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] + ) -> Bool { + false + } +} diff --git a/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift b/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift new file mode 100644 index 000000000..c9172c404 --- /dev/null +++ b/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift @@ -0,0 +1,13 @@ +// +// BrazeListener.swift +// OpenEdX +// +// Created by Anton Yarmolenka on 24/01/2024. +// + +import Foundation + +class BrazeListener: PushNotificationsListener { + // check if userinfo contains data for this Listener + func shouldListenNotification(userinfo: [AnyHashable: Any]) -> Bool { false } +} diff --git a/OpenEdX/Managers/PushNotificationsManager/Listeners/FCMListener.swift b/OpenEdX/Managers/PushNotificationsManager/Listeners/FCMListener.swift new file mode 100644 index 000000000..b0ed7f5f8 --- /dev/null +++ b/OpenEdX/Managers/PushNotificationsManager/Listeners/FCMListener.swift @@ -0,0 +1,13 @@ +// +// FCMListener.swift +// OpenEdX +// +// Created by Anton Yarmolenka on 24/01/2024. +// + +import Foundation + +class FCMListener: PushNotificationsListener { + // check if userinfo contains data for this Listener + func shouldListenNotification(userinfo: [AnyHashable: Any]) -> Bool { false } +} diff --git a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift new file mode 100644 index 000000000..2bb1d11f2 --- /dev/null +++ b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift @@ -0,0 +1,18 @@ +// +// BrazeProvider.swift +// OpenEdX +// +// Created by Anton Yarmolenka on 24/01/2024. +// + +import Foundation + +class BrazeProvider: PushNotificationsProvider { + func didRegisterWithDeviceToken(deviceToken: Data) { + + } + + func didFailToRegisterForRemoteNotificationsWithError(error: Error) { + + } +} diff --git a/OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift b/OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift new file mode 100644 index 000000000..4e66a2a30 --- /dev/null +++ b/OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift @@ -0,0 +1,18 @@ +// +// FCMProvider.swift +// OpenEdX +// +// Created by Anton Yarmolenka on 24/01/2024. +// + +import Foundation + +class FCMProvider: PushNotificationsProvider { + func didRegisterWithDeviceToken(deviceToken: Data) { + + } + + func didFailToRegisterForRemoteNotificationsWithError(error: Error) { + + } +} diff --git a/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift b/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift new file mode 100644 index 000000000..130818dd5 --- /dev/null +++ b/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift @@ -0,0 +1,97 @@ +// +// PushNotificationManager.swift +// OpenEdX +// +// Created by Anton Yarmolenka on 24/01/2024. +// + +import Foundation +import Core +import UIKit +import Swinject + +public protocol PushNotificationsProvider { + func didRegisterWithDeviceToken(deviceToken: Data) + func didFailToRegisterForRemoteNotificationsWithError(error: Error) +} + +protocol PushNotificationsListener { + func shouldListenNotification(userinfo: [AnyHashable: Any]) -> Bool + func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) +} + +extension PushNotificationsListener { + func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) { + guard let dictionary = userInfo as? [String: Any], + shouldListenNotification(userinfo: userInfo), + let deepLinkManager = Container.shared.resolve(DeepLinkManager.self) + else { return } + let link = PushLink(dictionary: dictionary) + deepLinkManager.processLinkFromNotification(link) + } +} + +class PushNotificationsManager { + private var providers: [PushNotificationsProvider] = [] + private var listeners: [PushNotificationsListener] = [] + + // Init manager + public init(config: ConfigProtocol) { + providers = providersFor(config: config) + listeners = listenersFor(config: config) + } + + private func providersFor(config: ConfigProtocol) -> [PushNotificationsProvider] { + var pushProviders: [PushNotificationsProvider] = [] + if config.firebase.cloudMessagingEnabled { + pushProviders.append(FCMProvider()) + } + if config.braze.pushNotificationsEnabled { + pushProviders.append(BrazeProvider()) + } + return pushProviders + } + + private func listenersFor(config: ConfigProtocol) -> [PushNotificationsListener] { + var pushListeners: [PushNotificationsListener] = [] + if config.firebase.cloudMessagingEnabled { + pushListeners.append(FCMListener()) + } + if config.braze.pushNotificationsEnabled { + pushListeners.append(BrazeListener()) + } + return pushListeners + } + + // Register for push notifications + public func performRegistration() { + UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { (granted, error) in + if granted { + DispatchQueue.main.async { + UIApplication.shared.registerForRemoteNotifications() + } + } else if let error = error { + debugLog("Push notifications permission error: \(error.localizedDescription)") + } else { + debugLog("Permission for push notifications denied.") + } + } + } + + // Proccess functions from app delegate + public func didRegisterForRemoteNotificationsWithDeviceToken(deviceToken: Data) { + for provider in providers { + provider.didRegisterWithDeviceToken(deviceToken: deviceToken) + } + } + public func didFailToRegisterForRemoteNotificationsWithError(error: Error) { + for provider in providers { + provider.didFailToRegisterForRemoteNotificationsWithError(error: error) + } + } + public func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) { + for listener in listeners { + listener.didReceiveRemoteNotification(userInfo: userInfo) + } + } +} From 291a399fc78f313bda9d6af5f88392e06e7f277d Mon Sep 17 00:00:00 2001 From: Gene <76485998+eyatsenkoperpetio@users.noreply.github.com> Date: Mon, 12 Feb 2024 14:04:54 +0100 Subject: [PATCH 010/136] Improvements for Download videos (#279) * chore: click loader and open download detail * chore: new delete video alert * chore: remove question mark * chore: back config directory * chore: resolve PR comments --- .../warning.imageset/Contents.json | 22 + .../warning.imageset/exclamation-mark 1.svg | 3 + .../warning.imageset/exclamation-mark 2.svg | 3 + Core/Core/Extensions/ViewExtension.swift | 13 + Core/Core/SwiftGen/Assets.swift | 1 + Core/Core/View/Base/AlertView.swift | 545 ++++++++++-------- Core/Core/View/Base/DownloadView.swift | 29 +- .../Container/CourseContainerView.swift | 2 +- .../Container/CourseContainerViewModel.swift | 8 + Course/Course/Presentation/CourseRouter.swift | 11 +- .../Downloads/DownloadsView.swift | 63 +- .../Downloads/DownloadsViewModel.swift | 5 + .../CourseStructureNestedListView.swift | 23 +- .../CourseVideoDownloadBarViewModel.swift | 6 +- OpenEdX/Router.swift | 11 +- .../Subviews/ProfileSupportInfoView.swift | 1 - 16 files changed, 462 insertions(+), 284 deletions(-) create mode 100644 Core/Core/Assets.xcassets/warning.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/warning.imageset/exclamation-mark 1.svg create mode 100644 Core/Core/Assets.xcassets/warning.imageset/exclamation-mark 2.svg diff --git a/Core/Core/Assets.xcassets/warning.imageset/Contents.json b/Core/Core/Assets.xcassets/warning.imageset/Contents.json new file mode 100644 index 000000000..417f9d554 --- /dev/null +++ b/Core/Core/Assets.xcassets/warning.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "exclamation-mark 1.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "exclamation-mark 2.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/warning.imageset/exclamation-mark 1.svg b/Core/Core/Assets.xcassets/warning.imageset/exclamation-mark 1.svg new file mode 100644 index 000000000..e79501009 --- /dev/null +++ b/Core/Core/Assets.xcassets/warning.imageset/exclamation-mark 1.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/warning.imageset/exclamation-mark 2.svg b/Core/Core/Assets.xcassets/warning.imageset/exclamation-mark 2.svg new file mode 100644 index 000000000..86b215583 --- /dev/null +++ b/Core/Core/Assets.xcassets/warning.imageset/exclamation-mark 2.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Extensions/ViewExtension.swift b/Core/Core/Extensions/ViewExtension.swift index 1c8833612..ef2e493a2 100644 --- a/Core/Core/Extensions/ViewExtension.swift +++ b/Core/Core/Extensions/ViewExtension.swift @@ -256,6 +256,19 @@ public extension View { } } +public extension View { + @ViewBuilder + func sheetNavigation(isSheet: Bool) -> some View { + if isSheet { + NavigationView { + self + } + } else { + self + } + } +} + private struct FirstAppear: ViewModifier { let action: () -> Void diff --git a/Core/Core/SwiftGen/Assets.swift b/Core/Core/SwiftGen/Assets.swift index 1d1c84625..50f49634f 100644 --- a/Core/Core/SwiftGen/Assets.swift +++ b/Core/Core/SwiftGen/Assets.swift @@ -109,6 +109,7 @@ public enum CoreAssets { public static let playVideo = ImageAsset(name: "playVideo") public static let star = ImageAsset(name: "star") public static let starOutline = ImageAsset(name: "star_outline") + public static let warning = ImageAsset(name: "warning") 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/View/Base/AlertView.swift b/Core/Core/View/Base/AlertView.swift index ad501bdb9..70e99bd2b 100644 --- a/Core/Core/View/Base/AlertView.swift +++ b/Core/Core/View/Base/AlertView.swift @@ -13,12 +13,13 @@ public enum AlertViewType: Equatable { case action(String, SwiftUI.Image) case logOut case leaveProfile - + case deleteVideo + var contentPadding: CGFloat { switch self { case .`default`: return 16 - case .action, .logOut, .leaveProfile: + case .action, .logOut, .leaveProfile, .deleteVideo: return 36 } } @@ -69,248 +70,342 @@ public struct AlertView: View { self.nextSectionTapped = nextSectionTapped type = .action(mainAction, image) } - + public var body: some View { ZStack(alignment: .center) { Color.black.opacity(0.5) .onTapGesture { onCloseTapped() } - ZStack(alignment: .topTrailing) { - adaptiveStack(spacing: isHorizontal ? 10 : 20, isHorizontal: (type == .leaveProfile && isHorizontal)) { - if type == .logOut { - HStack { - Spacer(minLength: 100) - CoreAssets.logOut.swiftUIImage - .padding(.top, isHorizontal ? 20 : 54) - Spacer(minLength: 100) - } - Text(alertMessage) - .font(Theme.Fonts.titleLarge) - .padding(.vertical, isHorizontal ? 6 : 40) - .multilineTextAlignment(.center) - .padding(.horizontal, 40) - .frame(maxWidth: 250) - } else if type == .leaveProfile { + content + } + .ignoresSafeArea() + } + + private var content: some View { + ZStack(alignment: .topTrailing) { + adaptiveStack( + spacing: isHorizontal ? 10 : 20, + isHorizontal: (type == .leaveProfile && isHorizontal) + ) { + titles + buttons + } + close + } + .frame(maxWidth: type == .logOut ? 390 : nil) + .background( + Theme.Shapes.cardShape + .fill(Theme.Colors.cardViewBackground) + .shadow(radius: 24) + .fixedSize(horizontal: false, vertical: false) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke( + style: .init( + lineWidth: 1, + lineCap: .round, + lineJoin: .round, + miterLimit: 1 + ) + ) + .foregroundColor(Theme.Colors.backgroundStroke) + .fixedSize(horizontal: false, vertical: false) + ) + .frame(maxWidth: isHorizontal ? nil : 390) + .padding(40) + } + + private var close: some View { + Button { + onCloseTapped() + } label: { + Image(systemName: "xmark") + .padding(.trailing, 40) + .padding(.top, 24) + } + } + + @ViewBuilder + private var titles: some View { + switch type { + case .logOut: + HStack { + Spacer(minLength: 100) + CoreAssets.logOut.swiftUIImage + .padding(.top, isHorizontal ? 20 : 54) + Spacer(minLength: 100) + } + Text(alertMessage) + .font(Theme.Fonts.titleLarge) + .padding(.vertical, isHorizontal ? 6 : 40) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + .frame(maxWidth: 250) + case .leaveProfile, .deleteVideo: + VStack(spacing: 20) { + if type == .deleteVideo { + CoreAssets.warning.swiftUIImage + .padding(.top, isHorizontal ? 20 : 54) + } else { + CoreAssets.leaveProfile.swiftUIImage + .padding(.top, isHorizontal ? 20 : 54) + } + Text(alertTitle) + .font(Theme.Fonts.titleLarge) + .padding(.horizontal, 40) + Text(alertMessage) + .font(Theme.Fonts.bodyMedium) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + + } + .padding(.bottom, 20) + default: + HStack { + VStack(alignment: .center, spacing: 10) { + if case let .action(_, image) = type { + image.padding(.top, 48) + } + if case let .default(_, image) = type { + image.flatMap { $0.padding(.top, 48) } + } + Text(alertTitle) + .font(Theme.Fonts.titleLarge) + .padding(.horizontal, 40) + .padding(.top, 10) + Text(alertMessage) + .font(Theme.Fonts.bodyMedium) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + .frame(maxWidth: 250) + } + if isHorizontal { + if case let .action(action, _) = type { VStack(spacing: 20) { - CoreAssets.leaveProfile.swiftUIImage - .padding(.top, isHorizontal ? 20 : 54) - Text(alertTitle) - .font(Theme.Fonts.titleLarge) - .padding(.horizontal, 40) - Text(alertMessage) - .font(Theme.Fonts.bodyMedium) - .multilineTextAlignment(.center) - .padding(.horizontal, 40) - - }.padding(.bottom, 20) - } else { - HStack { - VStack(alignment: .center, spacing: 10) { - if case let .action(_, image) = type { - image.padding(.top, 48) - } - if case let .default(_, image) = type { - image.flatMap { $0.padding(.top, 48) } - } - Text(alertTitle) - .font(Theme.Fonts.titleLarge) + if nextSectionName != nil { + UnitButtonView(type: .nextSection, action: { nextSectionTapped() }) + .frame(maxWidth: 215) + } + UnitButtonView(type: .custom(action), + bgColor: .clear, + action: { okTapped() }) + .frame(maxWidth: 215) + + if let nextSectionName { + Group { + Text(CoreLocalization.Courseware.nextSectionDescriptionFirst) + + Text(nextSectionName) + + Text(CoreLocalization.Courseware.nextSectionDescriptionLast) + }.frame(maxWidth: 215) .padding(.horizontal, 40) - .padding(.top, 10) - Text(alertMessage) - .font(Theme.Fonts.bodyMedium) .multilineTextAlignment(.center) - .padding(.horizontal, 40) - .frame(maxWidth: 250) + .font(Theme.Fonts.labelSmall) + .foregroundColor(Theme.Colors.textSecondary) } - if isHorizontal { - if case let .action(action, _) = type { - VStack(spacing: 20) { - if nextSectionName != nil { - UnitButtonView(type: .nextSection, action: { nextSectionTapped() }) - .frame(maxWidth: 215) - } - UnitButtonView(type: .custom(action), - bgColor: .clear, - action: { okTapped() }) - .frame(maxWidth: 215) - - if let nextSectionName { - Group { - Text(CoreLocalization.Courseware.nextSectionDescriptionFirst) + - Text(nextSectionName) + - Text(CoreLocalization.Courseware.nextSectionDescriptionLast) - }.frame(maxWidth: 215) - .padding(.horizontal, 40) - .multilineTextAlignment(.center) - .font(Theme.Fonts.labelSmall) - .foregroundColor(Theme.Colors.textSecondary) - } - }.padding(.top, 70) - .padding(.trailing, 20) - } - } - } + }.padding(.top, 70) + .padding(.trailing, 20) } - HStack { - switch type { - case let .`default`(positiveAction, _): - HStack { - StyledButton(positiveAction, action: { okTapped() }) - .frame(maxWidth: 135) - StyledButton(CoreLocalization.Alert.cancel, action: { onCloseTapped() }) - .frame(maxWidth: 135) - .saturation(0) - } - .padding(.leading, 10) - .padding(.trailing, 10) - .padding(.bottom, 10) - case let .action(action, _): - if !isHorizontal { - VStack(spacing: 20) { - if nextSectionName != nil { - UnitButtonView(type: .nextSection, action: { nextSectionTapped() }) - .frame(maxWidth: 215) - } - UnitButtonView(type: .custom(action), - bgColor: .clear, - action: { okTapped() }) - .frame(maxWidth: 215) - - if let nextSectionName { - Group { - Text(CoreLocalization.Courseware.nextSectionDescriptionFirst) + - Text(nextSectionName) + - Text(CoreLocalization.Courseware.nextSectionDescriptionLast) - }.frame(maxWidth: 215) - .padding(.horizontal, 40) - .multilineTextAlignment(.center) - .font(Theme.Fonts.labelSmall) - .foregroundColor(Theme.Colors.textSecondary) - } - } - } else { - EmptyView() - } - case .logOut: - Button(action: { - okTapped() - }, label: { - ZStack { - Text(CoreLocalization.Alert.logout) - .foregroundColor(.black) - .font(Theme.Fonts.labelLarge) - .frame(maxWidth: .infinity) - .padding(.horizontal, 16) - Image(systemName: "rectangle.portrait.and.arrow.right") - .foregroundColor(.black) - .frame(minWidth: 190, minHeight: 48, alignment: .trailing) - } - .frame(maxWidth: 215, minHeight: 48) - }) - .background( - Theme.Shapes.buttonShape - .fill(Theme.Colors.warning) - ) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(style: .init( - lineWidth: 1, - lineCap: .round, - lineJoin: .round, - miterLimit: 1 - )) - .foregroundColor(.clear) - ) - .frame(maxWidth: 215) - case .leaveProfile: - VStack(spacing: 0) { - Button(action: { - okTapped() - }, label: { - ZStack { - Text(CoreLocalization.Alert.leave) - .foregroundColor(.black) - .font(Theme.Fonts.labelLarge) - .frame(maxWidth: .infinity) - .padding(.horizontal, 16) - } - .frame(maxWidth: 215, minHeight: 48) - }) - .background( - Theme.Shapes.buttonShape - .fill(Theme.Colors.warning) - ) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(style: .init( - lineWidth: 1, - lineCap: .round, - lineJoin: .round, - miterLimit: 1 - )) - .foregroundColor(.clear) - ) - .frame(maxWidth: 215) - .padding(.bottom, isHorizontal ? 10 : 24) - Button(action: { - onCloseTapped() - }, label: { - ZStack { - Text(CoreLocalization.Alert.keepEditing) - .foregroundColor(Theme.Colors.textPrimary) - .font(Theme.Fonts.labelLarge) - .frame(maxWidth: .infinity) - .padding(.horizontal, 16) - } - .frame(maxWidth: 215, minHeight: 48) - }) - .background( - Theme.Shapes.buttonShape - .fill(.clear) - ) - .overlay( - RoundedRectangle(cornerRadius: 8) - .stroke(style: .init( - lineWidth: 1, - lineCap: .round, - lineJoin: .round, - miterLimit: 1 - )) - .foregroundColor(Theme.Colors.textPrimary) - ) + } + } + } + } + + private var buttons: some View { + HStack { + switch type { + case let .`default`(positiveAction, _): + HStack { + StyledButton(positiveAction, action: { okTapped() }) + .frame(maxWidth: 135) + StyledButton(CoreLocalization.Alert.cancel, action: { onCloseTapped() }) + .frame(maxWidth: 135) + .saturation(0) + } + .padding(.leading, 10) + .padding(.trailing, 10) + .padding(.bottom, 10) + case let .action(action, _): + if !isHorizontal { + VStack(spacing: 20) { + if nextSectionName != nil { + UnitButtonView(type: .nextSection, action: { nextSectionTapped() }) .frame(maxWidth: 215) - }.padding(.trailing, isHorizontal ? 20 : 0) + } + UnitButtonView(type: .custom(action), + bgColor: .clear, + action: { okTapped() }) + .frame(maxWidth: 215) + + if let nextSectionName { + Group { + Text(CoreLocalization.Courseware.nextSectionDescriptionFirst) + + Text(nextSectionName) + + Text(CoreLocalization.Courseware.nextSectionDescriptionLast) + }.frame(maxWidth: 215) + .padding(.horizontal, 40) + .multilineTextAlignment(.center) + .font(Theme.Fonts.labelSmall) + .foregroundColor(Theme.Colors.textSecondary) } } - .padding(.top, 16) - .padding(.bottom, isHorizontal ? 16 : type.contentPadding) + } else { + EmptyView() } + case .logOut: Button(action: { - onCloseTapped() + okTapped() }, label: { - Image(systemName: "xmark") - .padding(.trailing, 40) - .padding(.top, 24) + ZStack { + Text(CoreLocalization.Alert.logout) + .foregroundColor(.black) + .font(Theme.Fonts.labelLarge) + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + Image(systemName: "rectangle.portrait.and.arrow.right") + .foregroundColor(.black) + .frame(minWidth: 190, minHeight: 48, alignment: .trailing) + } + .frame(maxWidth: 215, minHeight: 48) }) - - }.frame(maxWidth: type == .logOut ? 390 : nil) - .background( - Theme.Shapes.cardShape - .fill(Theme.Colors.cardViewBackground) - .shadow(radius: 24) - .fixedSize(horizontal: false, vertical: false) - ) - .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke(style: .init(lineWidth: 1, lineCap: .round, lineJoin: .round, miterLimit: 1)) - .foregroundColor(Theme.Colors.backgroundStroke) - .fixedSize(horizontal: false, vertical: false) - ) - .frame(maxWidth: isHorizontal ? nil : 390) - .padding(40) + .background( + Theme.Shapes.buttonShape + .fill(Theme.Colors.warning) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(style: .init( + lineWidth: 1, + lineCap: .round, + lineJoin: .round, + miterLimit: 1 + )) + .foregroundColor(.clear) + ) + .frame(maxWidth: 215) + case .leaveProfile: + VStack(spacing: 0) { + Button(action: { + okTapped() + }, label: { + ZStack { + Text(CoreLocalization.Alert.leave) + .foregroundColor(.black) + .font(Theme.Fonts.labelLarge) + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + } + .frame(maxWidth: 215, minHeight: 48) + }) + .background( + Theme.Shapes.buttonShape + .fill(Theme.Colors.warning) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(style: .init( + lineWidth: 1, + lineCap: .round, + lineJoin: .round, + miterLimit: 1 + )) + .foregroundColor(.clear) + ) + .frame(maxWidth: 215) + .padding(.bottom, isHorizontal ? 10 : 24) + Button(action: { + onCloseTapped() + }, label: { + ZStack { + Text(CoreLocalization.Alert.keepEditing) + .foregroundColor(Theme.Colors.textPrimary) + .font(Theme.Fonts.labelLarge) + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + } + .frame(maxWidth: 215, minHeight: 48) + }) + .background( + Theme.Shapes.buttonShape + .fill(.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(style: .init( + lineWidth: 1, + lineCap: .round, + lineJoin: .round, + miterLimit: 1 + )) + .foregroundColor(Theme.Colors.textPrimary) + ) + .frame(maxWidth: 215) + } + .padding(.trailing, isHorizontal ? 20 : 0) + case .deleteVideo: + VStack(spacing: 0) { + Button { + okTapped() + } label: { + ZStack { + Text(CoreLocalization.Alert.delete) + .foregroundColor(.black) + .font(Theme.Fonts.labelLarge) + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + } + .frame(maxWidth: 215, minHeight: 48) + } + .background( + Theme.Shapes.buttonShape + .fill(Theme.Colors.warning) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(style: .init( + lineWidth: 1, + lineCap: .round, + lineJoin: .round, + miterLimit: 1 + )) + .foregroundColor(.clear) + ) + .frame(maxWidth: 215) + .padding(.bottom, isHorizontal ? 10 : 24) + Button(action: { + onCloseTapped() + }, label: { + ZStack { + Text(CoreLocalization.Alert.cancel) + .foregroundColor(Theme.Colors.textPrimary) + .font(Theme.Fonts.labelLarge) + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + } + .frame(maxWidth: 215, minHeight: 48) + }) + .background( + Theme.Shapes.buttonShape + .fill(.clear) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(style: .init( + lineWidth: 1, + lineCap: .round, + lineJoin: .round, + miterLimit: 1 + )) + .foregroundColor(Theme.Colors.textPrimary) + ) + .frame(maxWidth: 215) + } + .padding(.trailing, isHorizontal ? 20 : 0) + } } - .ignoresSafeArea() + .padding(.top, 16) + .padding(.bottom, isHorizontal ? 16 : type.contentPadding) } } diff --git a/Core/Core/View/Base/DownloadView.swift b/Core/Core/View/Base/DownloadView.swift index 806aee51b..37f63e41d 100644 --- a/Core/Core/View/Base/DownloadView.swift +++ b/Core/Core/View/Base/DownloadView.swift @@ -19,11 +19,14 @@ public struct DownloadAvailableView: View { } public var body: some View { - CoreAssets.startDownloading.swiftUIImage.renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: 24, height: 24) - .foregroundColor(Theme.Colors.textPrimary) + VStack(spacing: 0) { + CoreAssets.startDownloading.swiftUIImage.renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundColor(Theme.Colors.textPrimary) + } + .frame(width: 30, height: 30) } } @@ -33,13 +36,12 @@ public struct DownloadProgressView: View { public var body: some View { ZStack { - ProgressBar(size: 36, lineWidth: 1.75) + ProgressBar(size: 30, lineWidth: 1.75) CoreAssets.stopDownloading.swiftUIImage.renderingMode(.template) .resizable() .scaledToFit() .frame(width: 20, height: 20) .foregroundColor(Theme.Colors.textPrimary) - .padding(6) } } } @@ -49,10 +51,13 @@ public struct DownloadFinishedView: View { } public var body: some View { - CoreAssets.deleteDownloading.swiftUIImage.renderingMode(.template) - .resizable() - .scaledToFit() - .frame(width: 24, height: 24) - .foregroundColor(Theme.Colors.textPrimary) + VStack(spacing: 0) { + CoreAssets.deleteDownloading.swiftUIImage.renderingMode(.template) + .resizable() + .scaledToFit() + .frame(width: 24, height: 24) + .foregroundColor(Theme.Colors.textPrimary) + } + .frame(width: 30, height: 30) } } diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 2e154ba69..79a797ddd 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -20,8 +20,8 @@ public struct CourseContainerView: View { case course case videos - case dates case discussion + case dates case handounds var title: String { diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index ce5463cd9..9a7a34c4c 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -165,6 +165,14 @@ public class CourseContainerViewModel: BaseCourseViewModel { return verticals.flatMap { $0.vertical.childs.filter { $0.isDownloadable } } } + func getTasks(sequential: CourseSequential) -> [DownloadDataTask] { + let blocks = verticalsBlocksDownloadable(by: sequential) + let tasks = blocks.compactMap { block in + courseDownloadTasks.first(where: { $0.id == block.id}) + } + return tasks + } + func continueDownload() { guard let blocks = waitingDownloads else { return diff --git a/Course/Course/Presentation/CourseRouter.swift b/Course/Course/Presentation/CourseRouter.swift index 35619bc2d..d4cd7c68a 100644 --- a/Course/Course/Presentation/CourseRouter.swift +++ b/Course/Course/Presentation/CourseRouter.swift @@ -55,6 +55,11 @@ public protocol CourseRouter: BaseRouter { componentID: String, courseStructure: CourseStructure ) + + func showDownloads( + downloads: [DownloadDataTask], + manager: DownloadManagerProtocol + ) } // Mark - For testing and SwiftUI preview @@ -108,6 +113,10 @@ public class CourseRouterMock: BaseRouterMock, CourseRouter { componentID: String, courseStructure: CourseStructure ) {} - + + public func showDownloads( + downloads: [Core.DownloadDataTask], + manager: Core.DownloadManagerProtocol + ) {} } #endif diff --git a/Course/Course/Presentation/Downloads/DownloadsView.swift b/Course/Course/Presentation/Downloads/DownloadsView.swift index c6b0262d1..958380a6d 100644 --- a/Course/Course/Presentation/Downloads/DownloadsView.swift +++ b/Course/Course/Presentation/Downloads/DownloadsView.swift @@ -10,49 +10,64 @@ import Core import Theme import Combine -struct DownloadsView: View { +public struct DownloadsView: View { // MARK: - Properties @Environment(\.dismiss) private var dismiss @StateObject private var viewModel: DownloadsViewModel - init( + var isSheet: Bool = true + + public init( + isSheet: Bool = true, courseId: String? = nil, + downloads: [DownloadDataTask] = [], manager: DownloadManagerProtocol ) { + self.isSheet = isSheet self._viewModel = .init( - wrappedValue: .init(courseId: courseId, manager: manager) + wrappedValue: .init( + courseId: courseId, + downloads: downloads, + manager: manager + ) ) } // MARK: - Body - var body: some View { - NavigationView { - ScrollView { - LazyVStack { - ForEach( - viewModel.downloads, - content: cell - ) - } + public var body: some View { + content + .sheetNavigation(isSheet: isSheet) + } + + private var content: some View { + ScrollView { + LazyVStack { + ForEach( + viewModel.downloads, + content: cell + ) } - .navigationBarTitleDisplayMode(.inline) - .navigationTitle(CourseLocalization.Download.downloads) - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - dismiss() - } label: { - Image(systemName: "xmark") - .foregroundColor(Theme.Colors.accentColor) + } + .navigationBarTitleDisplayMode(.inline) + .navigationTitle(CourseLocalization.Download.downloads) + .if(isSheet) { view in + view + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + dismiss() + } label: { + Image(systemName: "xmark") + .foregroundColor(Theme.Colors.accentColor) + } + .accessibilityIdentifier("close_button") } - .accessibilityIdentifier("close_button") } - } - .padding(.top, 1) } + .padding(.top, 1) } // MARK: - Views diff --git a/Course/Course/Presentation/Downloads/DownloadsViewModel.swift b/Course/Course/Presentation/Downloads/DownloadsViewModel.swift index b71566f58..78c063778 100644 --- a/Course/Course/Presentation/Downloads/DownloadsViewModel.swift +++ b/Course/Course/Presentation/Downloads/DownloadsViewModel.swift @@ -21,10 +21,12 @@ final class DownloadsViewModel: ObservableObject { init( courseId: String? = nil, + downloads: [DownloadDataTask] = [], manager: DownloadManagerProtocol ) { self.courseId = courseId self.manager = manager + self.downloads = downloads Task { await configure() } observers() } @@ -52,6 +54,9 @@ final class DownloadsViewModel: ObservableObject { defer { filter() } + if !downloads.isEmpty { + return + } if let courseId = courseId { downloads = await manager.getDownloadTasksForCourse(courseId) return diff --git a/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift b/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift index 8cada0ce0..176594c23 100644 --- a/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift +++ b/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift @@ -131,22 +131,16 @@ struct CourseStructureNestedListView: View { .accessibilityElement(children: .ignore) .accessibilityLabel(CourseLocalization.Accessibility.download) } - downloadCount(sequential: sequential) } case .downloading: if viewModel.isInternetAvaliable { Button { - Task { - await viewModel.onDownloadViewTap( - chapter: chapter, - blockId: sequential.id, - state: state - ) - } + viewModel.router.showDownloads( + downloads: viewModel.getTasks(sequential: sequential), + manager: viewModel.manager + ) } label: { - DownloadProgressView() - .accessibilityElement(children: .ignore) - .accessibilityLabel(CourseLocalization.Accessibility.cancelDownload) + ProgressBar(size: 30, lineWidth: 1.75) } } case .finished: @@ -168,18 +162,15 @@ struct CourseStructureNestedListView: View { } viewModel.router.dismiss(animated: true) }, - type: .default( - positiveAction: CoreLocalization.Alert.delete, - image: CoreAssets.bgDelete.swiftUIImage - ) + type: .deleteVideo ) } label: { DownloadFinishedView() .accessibilityElement(children: .ignore) .accessibilityLabel(CourseLocalization.Accessibility.deleteDownload) } - downloadCount(sequential: sequential) } + downloadCount(sequential: sequential) } } diff --git a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift index 4a64dbf4f..91b0cd281 100644 --- a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift +++ b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift @@ -134,7 +134,7 @@ final class CourseVideoDownloadBarViewModel: ObservableObject { } self.courseViewModel.router.dismiss(animated: true) }, - type: .default(positiveAction: CoreLocalization.Alert.delete, image: CoreAssets.bgDelete.swiftUIImage) + type: .deleteVideo ) return } @@ -142,7 +142,7 @@ final class CourseVideoDownloadBarViewModel: ObservableObject { if isOn { courseViewModel.router.presentAlert( alertTitle: "Warning", - alertMessage: "\(CourseLocalization.Alert.stopDownloading) \"\(courseStructure.displayName)\"?", + alertMessage: "\(CourseLocalization.Alert.stopDownloading) \"\(courseStructure.displayName)\"", positiveAction: CoreLocalization.Alert.accept, onCloseTapped: { [weak self] in self?.courseViewModel.router.dismiss(animated: true) @@ -154,7 +154,7 @@ final class CourseVideoDownloadBarViewModel: ObservableObject { } self.courseViewModel.router.dismiss(animated: true) }, - type: .default(positiveAction: CoreLocalization.Alert.accept, image: nil) + type: .deleteVideo ) return } diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index adc46d1f6..10a267a53 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -410,7 +410,16 @@ public class Router: AuthorizationRouter, } } } - + + public func showDownloads( + downloads: [DownloadDataTask], + manager: DownloadManagerProtocol + ) { + let downloadsView = DownloadsView(isSheet: false, downloads: downloads, manager: manager) + let controller = UIHostingController(rootView: downloadsView) + navigationController.pushViewController(controller, animated: true) + } + public func replaceCourseUnit( courseName: String, blockId: String, diff --git a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift index 81b669355..da2695845 100644 --- a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift +++ b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift @@ -45,7 +45,6 @@ struct ProfileSupportInfoView: View { ), isEmailSupport: true ) - } private func terms(url: URL) -> some View { From c622576a75cf175bf1021b4dc37c29ce6785357b Mon Sep 17 00:00:00 2001 From: Vadim Kuznetsov Date: Mon, 12 Feb 2024 16:30:20 +0300 Subject: [PATCH 011/136] Feat/dark mode webview (#274) * feat: added dark mode to content webviews * chore: fix merge conflict * chore: renamed ColorInvertionInjection -> ColorInversionInjection * chore: renamed `invertionCss` property to `inversionCss` * chore: requested changes by @saeedbashir during PR --- Core/Core.xcodeproj/project.pbxproj | 4 + .../Models/ColorInversionInjection.swift | 32 ++++ .../Webview/Models/WebviewInjection.swift | 5 + Core/Core/View/Base/Webview/WebView.swift | 137 ++++++++++-------- .../Unit/CourseUnitViewModel.swift | 9 +- 5 files changed, 123 insertions(+), 64 deletions(-) create mode 100644 Core/Core/View/Base/Webview/Models/ColorInversionInjection.swift diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index bde6eadac..1c68a8ad8 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -91,6 +91,7 @@ 064987982B4D69FF0071642A /* CSSInjectionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 064987902B4D69FE0071642A /* CSSInjectionProtocol.swift */; }; 064987992B4D69FF0071642A /* WebViewScriptInjectionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 064987912B4D69FE0071642A /* WebViewScriptInjectionProtocol.swift */; }; 0649879A2B4D69FF0071642A /* WebViewHTML.swift in Sources */ = {isa = PBXBuildFile; fileRef = 064987922B4D69FE0071642A /* WebViewHTML.swift */; }; + 06BEEA0E2B6A55C500D25A97 /* ColorInversionInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BEEA0D2B6A55C500D25A97 /* ColorInversionInjection.swift */; }; 070019A528F6F17900D5FC78 /* Data_Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = 070019A428F6F17900D5FC78 /* Data_Media.swift */; }; 070019AC28F6FD0100D5FC78 /* CourseDetailBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 070019AB28F6FD0100D5FC78 /* CourseDetailBlock.swift */; }; 070019AE28F701B200D5FC78 /* Certificate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 070019AD28F701B200D5FC78 /* Certificate.swift */; }; @@ -258,6 +259,7 @@ 064987902B4D69FE0071642A /* CSSInjectionProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CSSInjectionProtocol.swift; sourceTree = ""; }; 064987912B4D69FE0071642A /* WebViewScriptInjectionProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebViewScriptInjectionProtocol.swift; sourceTree = ""; }; 064987922B4D69FE0071642A /* WebViewHTML.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebViewHTML.swift; sourceTree = ""; }; + 06BEEA0D2B6A55C500D25A97 /* ColorInversionInjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorInversionInjection.swift; sourceTree = ""; }; 070019A428F6F17900D5FC78 /* Data_Media.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_Media.swift; sourceTree = ""; }; 070019AB28F6FD0100D5FC78 /* CourseDetailBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDetailBlock.swift; sourceTree = ""; }; 070019AD28F701B200D5FC78 /* Certificate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Certificate.swift; sourceTree = ""; }; @@ -503,6 +505,7 @@ 0649878B2B4D69FE0071642A /* WebviewInjection.swift */, 0649878C2B4D69FE0071642A /* SurveyCssInjection.swift */, 0649878D2B4D69FE0071642A /* WebviewMessage.swift */, + 06BEEA0D2B6A55C500D25A97 /* ColorInversionInjection.swift */, ); path = Models; sourceTree = ""; @@ -1010,6 +1013,7 @@ 027BD3B92909476200392132 /* KeyboardAvoidingModifier.swift in Sources */, BA4AFB422B5A7A0900A21367 /* VideoDownloadQualityView.swift in Sources */, 0770DE2C28D092B3006D8A5D /* NetworkLogger.swift in Sources */, + 06BEEA0E2B6A55C500D25A97 /* ColorInversionInjection.swift in Sources */, 064987972B4D69FF0071642A /* WebView.swift in Sources */, 0770DE2A28D0929E006D8A5D /* HTTPTask.swift in Sources */, 02284C182A3B1AE00007117F /* UIApplicationExtension.swift in Sources */, diff --git a/Core/Core/View/Base/Webview/Models/ColorInversionInjection.swift b/Core/Core/View/Base/Webview/Models/ColorInversionInjection.swift new file mode 100644 index 000000000..cf842acf7 --- /dev/null +++ b/Core/Core/View/Base/Webview/Models/ColorInversionInjection.swift @@ -0,0 +1,32 @@ +// +// ColorInversionInjection.swift +// Core +// +// Created by Vadim Kuznetsov on 31.01.24. +// + +import WebKit + +public struct ColorInversionInjection: WebViewScriptInjectionProtocol, CSSInjectionProtocol { + public var id: String = "ColorInvertionInjection" + public var script: String { + let css = """ + @media (prefers-color-scheme: dark) { + html { + filter: invert(100%) hue-rotate(180deg); + background-color: transparent !important; + } + body { + background-color: transparent !important; + } + img, video, iframe { + filter: invert(100%) hue-rotate(180deg) !important; + } + } + """ + return cssScript(with: css) + } + public var messages: [WebviewMessage]? + public var injectionTime: WKUserScriptInjectionTime = .atDocumentStart + public var forMainFrameOnly: Bool = true +} diff --git a/Core/Core/View/Base/Webview/Models/WebviewInjection.swift b/Core/Core/View/Base/Webview/Models/WebviewInjection.swift index 8ac215625..d4cc71803 100644 --- a/Core/Core/View/Base/Webview/Models/WebviewInjection.swift +++ b/Core/Core/View/Base/Webview/Models/WebviewInjection.swift @@ -47,6 +47,11 @@ public extension WebviewInjection { .webviewInjection() } + static var colorInversionCss: WebviewInjection { + ColorInversionInjection() + .webviewInjection() + } + static var ajaxCallback: WebviewInjection { AjaxInjection() .webviewInjection() diff --git a/Core/Core/View/Base/Webview/WebView.swift b/Core/Core/View/Base/Webview/WebView.swift index db2e754ed..54fa11796 100644 --- a/Core/Core/View/Base/Webview/WebView.swift +++ b/Core/Core/View/Base/Webview/WebView.swift @@ -51,65 +51,6 @@ public struct WebView: UIViewRepresentable { self.webViewNavDelegate = navigationDelegate } - public func makeCoordinator() -> Coordinator { - Coordinator(self) - } - - public func makeUIView(context: UIViewRepresentableContext) -> WKWebView { - let webViewConfig = WKWebViewConfiguration() - - let webView = WKWebView(frame: .zero, configuration: webViewConfig) - #if DEBUG - if #available(iOS 16.4, *) { - webView.isInspectable = true - } - #endif - webView.navigationDelegate = context.coordinator - webView.uiDelegate = context.coordinator - - context.coordinator.webview = webView - - webView.scrollView.bounces = false - webView.scrollView.alwaysBounceHorizontal = false - webView.scrollView.showsHorizontalScrollIndicator = false - webView.scrollView.isScrollEnabled = true - webView.configuration.suppressesIncrementalRendering = true - webView.isOpaque = false - webView.backgroundColor = .clear - webView.scrollView.backgroundColor = Theme.Colors.white.uiColor() - webView.scrollView.alwaysBounceVertical = false - webView.scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 200, right: 0) - - for injection in viewModel.injections ?? [] { - let script = WKUserScript( - source: injection.script, - injectionTime: injection.injectionTime, - forMainFrameOnly: injection.forMainFrameOnly - ) - webView.configuration.userContentController.addUserScript(script) - - for message in injection.messages ?? [] { - webView.configuration.userContentController.add(context.coordinator, name: message.name) - } - } - - webView.customUserAgent = userAgent - - return webView - } - - public func updateUIView(_ webview: WKWebView, context: UIViewRepresentableContext) { - if let url = URL(string: viewModel.url) { - if webview.url?.absoluteString != url.absoluteString { - DispatchQueue.main.async { - isLoading = true - } - let request = URLRequest(url: url) - webview.load(request) - } - } - } - public class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler { var parent: WebView @@ -120,7 +61,24 @@ public struct WebView: UIViewRepresentable { addObserver() } + public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { + webView.isHidden = true + } + + public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { + webView.isHidden = false + } + + public func webView( + _ webView: WKWebView, + didFailProvisionalNavigation navigation: WKNavigation!, + withError error: Error + ) { + webView.isHidden = false + } + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + webView.isHidden = false DispatchQueue.main.async { self.parent.isLoading = false } @@ -217,7 +175,7 @@ public struct WebView: UIViewRepresentable { object: nil ) } - + fileprivate var webview: WKWebView? @objc private func reload() { @@ -251,6 +209,65 @@ public struct WebView: UIViewRepresentable { .joined(separator: "/") } + public func makeCoordinator() -> Coordinator { + Coordinator(self) + } + + public func makeUIView(context: UIViewRepresentableContext) -> WKWebView { + let webViewConfig = WKWebViewConfiguration() + + let webView = WKWebView(frame: .zero, configuration: webViewConfig) + #if DEBUG + if #available(iOS 16.4, *) { + webView.isInspectable = true + } + #endif + webView.navigationDelegate = context.coordinator + webView.uiDelegate = context.coordinator + + context.coordinator.webview = webView + + webView.scrollView.bounces = false + webView.scrollView.alwaysBounceHorizontal = false + webView.scrollView.showsHorizontalScrollIndicator = false + webView.scrollView.isScrollEnabled = true + webView.configuration.suppressesIncrementalRendering = true + webView.isOpaque = false + webView.backgroundColor = .clear + webView.scrollView.backgroundColor = Theme.Colors.background.uiColor() + webView.scrollView.alwaysBounceVertical = false + webView.scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 200, right: 0) + + for injection in viewModel.injections ?? [] { + let script = WKUserScript( + source: injection.script, + injectionTime: injection.injectionTime, + forMainFrameOnly: injection.forMainFrameOnly + ) + webView.configuration.userContentController.addUserScript(script) + + for message in injection.messages ?? [] { + webView.configuration.userContentController.add(context.coordinator, name: message.name) + } + } + + webView.customUserAgent = userAgent + + return webView + } + + public func updateUIView(_ webview: WKWebView, context: UIViewRepresentableContext) { + if let url = URL(string: viewModel.url) { + if webview.url?.absoluteString != url.absoluteString { + DispatchQueue.main.async { + isLoading = true + } + let request = URLRequest(url: url) + webview.load(request) + } + } + } + public static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) { uiView.configuration.userContentController.removeAllUserScripts() uiView.configuration.userContentController.removeAllScriptMessageHandlers() diff --git a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift index 51e234e18..6bde8a84d 100644 --- a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift +++ b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift @@ -16,11 +16,12 @@ public enum LessonType: Equatable { case discussion(String, String, String) static func from(_ block: CourseBlock, streamingQuality: StreamingQuality) -> Self { + let mandatoryInjections: [WebviewInjection] = [.colorInversionCss, .ajaxCallback] switch block.type { case .course, .chapter, .vertical, .sequential, .unknown: return .unknown(block.studentUrl) case .html: - return .web(url: block.studentUrl, injections: [.ajaxCallback]) + return .web(url: block.studentUrl, injections: mandatoryInjections) case .discussion: return .discussion(block.topicId ?? "", block.id, block.displayName) case .video: @@ -35,11 +36,11 @@ public enum LessonType: Equatable { } case .problem: - return .web(url: block.studentUrl, injections: [.ajaxCallback]) + return .web(url: block.studentUrl, injections: mandatoryInjections) case .dragAndDropV2: - return .web(url: block.studentUrl, injections: [.ajaxCallback, .dragAndDropCss]) + return .web(url: block.studentUrl, injections: mandatoryInjections + [.dragAndDropCss]) case .survey: - return .web(url: block.studentUrl, injections: [.ajaxCallback, .surveyCSS]) + return .web(url: block.studentUrl, injections: mandatoryInjections + [.surveyCSS]) } } } From 1ed2628eb19deda6cf81d543ae50c3a2ff87f846 Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Tue, 13 Feb 2024 09:49:39 +0500 Subject: [PATCH 012/136] refactor: apply opacity to button bg color when disabled. --- Core/Core/View/Base/StyledButton.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Core/View/Base/StyledButton.swift b/Core/Core/View/Base/StyledButton.swift index 0de93feed..eb64959d1 100644 --- a/Core/Core/View/Base/StyledButton.swift +++ b/Core/Core/View/Base/StyledButton.swift @@ -48,6 +48,7 @@ public struct StyledButton: View { .background( Theme.Shapes.buttonShape .fill(isTransparent ? .clear : buttonColor) + .opacity(isActive ? 1.0 : 0.3) ) .overlay( Theme.Shapes.buttonShape @@ -57,7 +58,6 @@ public struct StyledButton: View { ) .accessibilityElement(children: .ignore) .accessibilityLabel(title) - .opacity(isActive ? 1.0 : 0.3) } } From 5a61d28549c6fd2aedbc96eab8560cad35f2c5f0 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Thu, 15 Feb 2024 15:24:23 +0100 Subject: [PATCH 013/136] chore: added AnalyticsService protocol and instances --- OpenEdX.xcodeproj/project.pbxproj | 19 ++++++++- OpenEdX/AppDelegate.swift | 11 +---- OpenEdX/DI/AppAssembly.swift | 6 ++- .../AnalyticsManager/AnalyticsManager.swift | 42 +++++++++++++------ .../Services/GoogleAnalyticsService.swift | 18 ++++++++ .../Services/SegmentAnalyticsService.swift | 26 ++++++++++++ 6 files changed, 96 insertions(+), 26 deletions(-) create mode 100644 OpenEdX/Managers/AnalyticsManager/Services/GoogleAnalyticsService.swift create mode 100644 OpenEdX/Managers/AnalyticsManager/Services/SegmentAnalyticsService.swift diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 674797359..ad68ce675 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -53,6 +53,8 @@ A51CDBE52B6D1E93009B6D4E /* Segment in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBE42B6D1E93009B6D4E /* Segment */; }; A51CDBED2B6D2BEE009B6D4E /* SegmentBraze in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBEC2B6D2BEE009B6D4E /* SegmentBraze */; }; A51CDBEF2B6D2BEE009B6D4E /* SegmentBrazeUI in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBEE2B6D2BEE009B6D4E /* SegmentBrazeUI */; }; + A52508082B7E4FCC0078E1D8 /* GoogleAnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A52508072B7E4FCC0078E1D8 /* GoogleAnalyticsService.swift */; }; + A525080A2B7E500A0078E1D8 /* SegmentAnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A52508092B7E500A0078E1D8 /* SegmentAnalyticsService.swift */; }; A59568952B61630500ED4F90 /* DeepLinkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568942B61630500ED4F90 /* DeepLinkManager.swift */; }; A59568972B61653700ED4F90 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568962B61653700ED4F90 /* DeepLink.swift */; }; A59568992B616D9400ED4F90 /* PushLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568982B616D9400ED4F90 /* PushLink.swift */; }; @@ -132,10 +134,12 @@ A50066902B61467B0024680B /* BrazeProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrazeProvider.swift; sourceTree = ""; }; A50066922B614DCD0024680B /* FCMListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FCMListener.swift; sourceTree = ""; }; A50066942B614DEF0024680B /* BrazeListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrazeListener.swift; sourceTree = ""; }; + A52508072B7E4FCC0078E1D8 /* GoogleAnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleAnalyticsService.swift; sourceTree = ""; }; + A52508092B7E500A0078E1D8 /* SegmentAnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentAnalyticsService.swift; sourceTree = ""; }; A59568942B61630500ED4F90 /* DeepLinkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkManager.swift; sourceTree = ""; }; A59568962B61653700ED4F90 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; A59568982B616D9400ED4F90 /* PushLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushLink.swift; sourceTree = ""; }; - A59585AE2B62A07100A35A20 /* BranchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BranchService.swift; sourceTree = ""; }; + A59585AE2B62A07100A35A20 /* BranchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BranchService.swift; path = ../BranchService.swift; sourceTree = ""; }; A89AD827F52CF6A6B903606E /* Pods-App-OpenEdX.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasestage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasestage.xcconfig"; sourceTree = ""; }; BB08ACD2CCA33D6DDDDD31B4 /* Pods-App-OpenEdX.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasedev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasedev.xcconfig"; sourceTree = ""; }; E0D6E6A22B1626B10089F9C9 /* Theme.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Theme.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -295,6 +299,7 @@ isa = PBXGroup; children = ( 0298DF2F2A4EF7230023A257 /* AnalyticsManager.swift */, + A52508062B7E4FA90078E1D8 /* Services */, 02F175302A4DA95B0019CD70 /* MainScreenAnalytics.swift */, ); path = AnalyticsManager; @@ -318,11 +323,19 @@ path = Listeners; sourceTree = ""; }; + A52508062B7E4FA90078E1D8 /* Services */ = { + isa = PBXGroup; + children = ( + A52508072B7E4FCC0078E1D8 /* GoogleAnalyticsService.swift */, + A52508092B7E500A0078E1D8 /* SegmentAnalyticsService.swift */, + ); + path = Services; + sourceTree = ""; + }; A59568932B6162E400ED4F90 /* DeepLinkManager */ = { isa = PBXGroup; children = ( A59568942B61630500ED4F90 /* DeepLinkManager.swift */, - A59585AE2B62A07100A35A20 /* BranchService.swift */, A5F46FD02B692B140003EEEF /* Services */, A59585AD2B62677B00A35A20 /* Link */, ); @@ -526,6 +539,8 @@ 020CA5D92AA0A25300970AAF /* AppStorage.swift in Sources */, 0298DF302A4EF7230023A257 /* AnalyticsManager.swift in Sources */, A59585AF2B62A07100A35A20 /* BranchService.swift in Sources */, + A525080A2B7E500A0078E1D8 /* SegmentAnalyticsService.swift in Sources */, + A52508082B7E4FCC0078E1D8 /* GoogleAnalyticsService.swift in Sources */, A50066912B61467B0024680B /* BrazeProvider.swift in Sources */, 0293A2072A6FCDA30090A336 /* DiscoveryPersistence.swift in Sources */, 07D5DA3528D075AA00752FD9 /* AppDelegate.swift in Sources */, diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index c13eadbba..6f7ee65f4 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -9,16 +9,13 @@ import UIKit import Core import Swinject import FirebaseCore -//import FirebaseAnalytics import FirebaseCrashlytics import Profile import GoogleSignIn import FacebookCore import MSAL import Theme -//import BrazeKit import Segment -//import SegmentFirebase @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { @@ -27,7 +24,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { UIApplication.shared.delegate as! AppDelegate } -// var braze: Braze? var analytics: Analytics? var window: UIWindow? @@ -60,9 +56,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { .trackApplicationLifecycleEvents(true) .flushInterval(10) analytics = Analytics(configuration: configuration) -// if config.firebase.isAnalyticsSourceSegment { -// analytics?.add(plugin: FirebaseDestination()) -// } } } @@ -145,8 +138,8 @@ class AppDelegate: UIResponder, UIApplicationDelegate { guard Date().timeIntervalSince1970 - lastForceLogoutTime > 5 else { return } - let analytics = Container.shared.resolve(AnalyticsManager.self) - analytics?.userLogout(force: true) + let analyticsManager = Container.shared.resolve(AnalyticsManager.self) + analyticsManager?.userLogout(force: true) lastForceLogoutTime = Date().timeIntervalSince1970 diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 8a98db84c..e2ebd1479 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -35,8 +35,10 @@ class AppAssembly: Assembly { Router(navigationController: r.resolve(UINavigationController.self)!, container: container) } - container.register(AnalyticsManager.self) { _ in - AnalyticsManager() + container.register(AnalyticsManager.self) { r in + AnalyticsManager( + config: r.resolve(ConfigProtocol.self)! + ) } container.register(AuthorizationAnalytics.self) { r in diff --git a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift index 9e3aa6e7f..16c0500ed 100644 --- a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift +++ b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift @@ -7,14 +7,17 @@ import Foundation import Core -import FirebaseAnalytics import Authorization import Discovery import Dashboard import Profile import Course import Discussion -import UIKit + +protocol AnalyticsService { + func identify(id: String, username: String?, email: String?) + func logEvent(_ event: Event, parameters: [String: Any]?) +} class AnalyticsManager: AuthorizationAnalytics, MainScreenAnalytics, @@ -24,13 +27,28 @@ class AnalyticsManager: AuthorizationAnalytics, CourseAnalytics, DiscussionAnalytics { + private var services: [AnalyticsService] = [] + + // Init Analytics Manager + public init(config: ConfigProtocol) { + services = servicesFor(config: config) + } + + private func servicesFor(config: ConfigProtocol) -> [AnalyticsService] { + var analyticsServices: [AnalyticsService] = [] + // add Google Analytics Service + analyticsServices.append(GoogleAnalyticsService()) + // add Segment Analytics Service if enabled + if config.segment.enabled { + analyticsServices.append(SegmentAnalyticsService()) + } + return analyticsServices + } + public func identify(id: String, username: String, email: String) { - Analytics.setUserID(id) - let traits: [String: String] = [ - "email": email, - "username": username - ] - (UIApplication.shared.delegate as? AppDelegate)?.analytics?.identify(userId: id, traits: traits) + for service in services { + service.identify(id: id, username: username, email: email) + } } public func userLogin(method: AuthMethod) { @@ -315,11 +333,9 @@ class AnalyticsManager: AuthorizationAnalytics, } private func logEvent(_ event: Event, parameters: [String: Any]? = nil) { - Analytics.logEvent(event.rawValue, parameters: parameters) - (UIApplication.shared.delegate as? AppDelegate)?.analytics?.track( - name: event.rawValue, - properties: parameters - ) + for service in services { + service.logEvent(event, parameters: parameters) + } } } diff --git a/OpenEdX/Managers/AnalyticsManager/Services/GoogleAnalyticsService.swift b/OpenEdX/Managers/AnalyticsManager/Services/GoogleAnalyticsService.swift new file mode 100644 index 000000000..608b83ad3 --- /dev/null +++ b/OpenEdX/Managers/AnalyticsManager/Services/GoogleAnalyticsService.swift @@ -0,0 +1,18 @@ +// +// GoogleAnalyticsService.swift +// OpenEdX +// +// Created by Anton Yarmolenka on 15/02/2024. +// + +import Foundation +import FirebaseAnalytics + +class GoogleAnalyticsService: AnalyticsService { + func identify(id: String, username: String?, email: String?) { + Analytics.setUserID(id) + } + func logEvent(_ event: Event, parameters: [String: Any]?) { + Analytics.logEvent(event.rawValue, parameters: parameters) + } +} diff --git a/OpenEdX/Managers/AnalyticsManager/Services/SegmentAnalyticsService.swift b/OpenEdX/Managers/AnalyticsManager/Services/SegmentAnalyticsService.swift new file mode 100644 index 000000000..5515e5bc6 --- /dev/null +++ b/OpenEdX/Managers/AnalyticsManager/Services/SegmentAnalyticsService.swift @@ -0,0 +1,26 @@ +// +// SegmentAnalyticsService.swift +// OpenEdX +// +// Created by Anton Yarmolenka on 15/02/2024. +// + +import Foundation +import UIKit + +class SegmentAnalyticsService: AnalyticsService { + func identify(id: String, username: String?, email: String?) { + guard let email = email, let username = username else { return } + let traits: [String: String] = [ + "email": email, + "username": username + ] + (UIApplication.shared.delegate as? AppDelegate)?.analytics?.identify(userId: id, traits: traits) + } + func logEvent(_ event: Event, parameters: [String: Any]?) { + (UIApplication.shared.delegate as? AppDelegate)?.analytics?.track( + name: event.rawValue, + properties: parameters + ) + } +} From 5d9dd2be71130cfa2302a1d4821b201839e69279 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Thu, 15 Feb 2024 15:45:33 +0100 Subject: [PATCH 014/136] chore: cleanup code --- Core/Core.xcodeproj/project.pbxproj | 2 +- .../PushNotificationsManager/Providers/BrazeProvider.swift | 2 -- Podfile | 3 --- 3 files changed, 1 insertion(+), 6 deletions(-) diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index a09765787..ac3e61e30 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -125,10 +125,10 @@ 0770DE6128D0B2CB006D8A5D /* Assets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE6028D0B2CB006D8A5D /* Assets.swift */; }; 07DDFCBD29A780BB00572595 /* UINavigationController+Animation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07DDFCBC29A780BB00572595 /* UINavigationController+Animation.swift */; }; 07E0939F2B308D2800F1E4B2 /* Data_Certificate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E0939E2B308D2800F1E4B2 /* Data_Certificate.swift */; }; + 141F1D302B7328D4009E81EB /* WebviewCookiesUpdateProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 141F1D2F2B7328D4009E81EB /* WebviewCookiesUpdateProtocol.swift */; }; A51188632B729647004E9F8E /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = A51188622B729647004E9F8E /* FirebaseAnalytics */; }; A51188652B72964F004E9F8E /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = A51188642B72964F004E9F8E /* FirebaseCrashlytics */; }; A51CDBE72B6D21F2009B6D4E /* SegmentConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51CDBE62B6D21F2009B6D4E /* SegmentConfig.swift */; }; - 141F1D302B7328D4009E81EB /* WebviewCookiesUpdateProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 141F1D2F2B7328D4009E81EB /* WebviewCookiesUpdateProtocol.swift */; }; A53A32352B233DEC005FE38A /* ThemeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A32342B233DEC005FE38A /* ThemeConfig.swift */; }; A595689B2B6173DF00ED4F90 /* BranchConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A595689A2B6173DF00ED4F90 /* BranchConfig.swift */; }; A5F4E7B52B61544A00ACD166 /* BrazeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */; }; diff --git a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift index f732ba089..b086af806 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift @@ -6,9 +6,7 @@ // import Foundation -//import BrazeKit import UIKit -import Segment import SegmentBrazeUI class BrazeProvider: PushNotificationsProvider { diff --git a/Podfile b/Podfile index 0a785d68f..b644e0124 100644 --- a/Podfile +++ b/Podfile @@ -16,9 +16,6 @@ abstract_target "App" do target "Core" do project './Core/Core.xcodeproj' workspace './Core/Core.xcodeproj' - #Firebase -# pod 'FirebaseAnalytics', '~> 10.11' -# pod 'FirebaseCrashlytics', '~> 10.11' #Networking pod 'Alamofire', '~> 5.7' #Keychain From c48416cd3baa2e60cabc3b066e3b89551ef21dbe Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Fri, 16 Feb 2024 14:02:46 +0100 Subject: [PATCH 015/136] chore: renamed google analytics to firebase analitics. added checking if firebase enabled --- OpenEdX.xcodeproj/project.pbxproj | 8 ++++---- OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift | 6 ++++-- ...lyticsService.swift => FirebaseAnalyticsService.swift} | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) rename OpenEdX/Managers/AnalyticsManager/Services/{GoogleAnalyticsService.swift => FirebaseAnalyticsService.swift} (88%) diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index ad68ce675..616ad4b81 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -53,7 +53,7 @@ A51CDBE52B6D1E93009B6D4E /* Segment in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBE42B6D1E93009B6D4E /* Segment */; }; A51CDBED2B6D2BEE009B6D4E /* SegmentBraze in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBEC2B6D2BEE009B6D4E /* SegmentBraze */; }; A51CDBEF2B6D2BEE009B6D4E /* SegmentBrazeUI in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBEE2B6D2BEE009B6D4E /* SegmentBrazeUI */; }; - A52508082B7E4FCC0078E1D8 /* GoogleAnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A52508072B7E4FCC0078E1D8 /* GoogleAnalyticsService.swift */; }; + A52508082B7E4FCC0078E1D8 /* FirebaseAnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A52508072B7E4FCC0078E1D8 /* FirebaseAnalyticsService.swift */; }; A525080A2B7E500A0078E1D8 /* SegmentAnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A52508092B7E500A0078E1D8 /* SegmentAnalyticsService.swift */; }; A59568952B61630500ED4F90 /* DeepLinkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568942B61630500ED4F90 /* DeepLinkManager.swift */; }; A59568972B61653700ED4F90 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568962B61653700ED4F90 /* DeepLink.swift */; }; @@ -134,7 +134,7 @@ A50066902B61467B0024680B /* BrazeProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrazeProvider.swift; sourceTree = ""; }; A50066922B614DCD0024680B /* FCMListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FCMListener.swift; sourceTree = ""; }; A50066942B614DEF0024680B /* BrazeListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrazeListener.swift; sourceTree = ""; }; - A52508072B7E4FCC0078E1D8 /* GoogleAnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleAnalyticsService.swift; sourceTree = ""; }; + A52508072B7E4FCC0078E1D8 /* FirebaseAnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseAnalyticsService.swift; sourceTree = ""; }; A52508092B7E500A0078E1D8 /* SegmentAnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentAnalyticsService.swift; sourceTree = ""; }; A59568942B61630500ED4F90 /* DeepLinkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkManager.swift; sourceTree = ""; }; A59568962B61653700ED4F90 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; @@ -326,7 +326,7 @@ A52508062B7E4FA90078E1D8 /* Services */ = { isa = PBXGroup; children = ( - A52508072B7E4FCC0078E1D8 /* GoogleAnalyticsService.swift */, + A52508072B7E4FCC0078E1D8 /* FirebaseAnalyticsService.swift */, A52508092B7E500A0078E1D8 /* SegmentAnalyticsService.swift */, ); path = Services; @@ -540,7 +540,7 @@ 0298DF302A4EF7230023A257 /* AnalyticsManager.swift in Sources */, A59585AF2B62A07100A35A20 /* BranchService.swift in Sources */, A525080A2B7E500A0078E1D8 /* SegmentAnalyticsService.swift in Sources */, - A52508082B7E4FCC0078E1D8 /* GoogleAnalyticsService.swift in Sources */, + A52508082B7E4FCC0078E1D8 /* FirebaseAnalyticsService.swift in Sources */, A50066912B61467B0024680B /* BrazeProvider.swift in Sources */, 0293A2072A6FCDA30090A336 /* DiscoveryPersistence.swift in Sources */, 07D5DA3528D075AA00752FD9 /* AppDelegate.swift in Sources */, diff --git a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift index 16c0500ed..e4354d18a 100644 --- a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift +++ b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift @@ -36,8 +36,10 @@ class AnalyticsManager: AuthorizationAnalytics, private func servicesFor(config: ConfigProtocol) -> [AnalyticsService] { var analyticsServices: [AnalyticsService] = [] - // add Google Analytics Service - analyticsServices.append(GoogleAnalyticsService()) + // add Firebase Analytics Service if enabled and have config + if config.firebase.firebaseOptions != nil { + analyticsServices.append(FirebaseAnalyticsService()) + } // add Segment Analytics Service if enabled if config.segment.enabled { analyticsServices.append(SegmentAnalyticsService()) diff --git a/OpenEdX/Managers/AnalyticsManager/Services/GoogleAnalyticsService.swift b/OpenEdX/Managers/AnalyticsManager/Services/FirebaseAnalyticsService.swift similarity index 88% rename from OpenEdX/Managers/AnalyticsManager/Services/GoogleAnalyticsService.swift rename to OpenEdX/Managers/AnalyticsManager/Services/FirebaseAnalyticsService.swift index 608b83ad3..271591360 100644 --- a/OpenEdX/Managers/AnalyticsManager/Services/GoogleAnalyticsService.swift +++ b/OpenEdX/Managers/AnalyticsManager/Services/FirebaseAnalyticsService.swift @@ -8,7 +8,7 @@ import Foundation import FirebaseAnalytics -class GoogleAnalyticsService: AnalyticsService { +class FirebaseAnalyticsService: AnalyticsService { func identify(id: String, username: String?, email: String?) { Analytics.setUserID(id) } From dde820134823a7be8b80eebdf0dcb3e0685b12a6 Mon Sep 17 00:00:00 2001 From: Vadim Kuznetsov Date: Mon, 19 Feb 2024 17:55:29 +0300 Subject: [PATCH 016/136] Open assessment and Peer instruction tool contents types (#282) * chore: added 'openassessment' type support * feat: added Peer Instruction Tool * chore: review changes regarding to comment https://github.com/openedx/openedx-app-ios/pull/282#pullrequestreview-1884954474 * fix: tests * chore: renamed property --------- Co-authored-by: Anton Yarmolenko --- Core/Core/Domain/Model/CourseBlockModel.swift | 5 +++- .../Core/Domain/Model/CourseDetailBlock.swift | 2 ++ Course/Course/Data/CourseRepository.swift | 6 +++-- .../Model/Data_CourseOutlineResponse.swift | 6 ++++- .../CourseCoreModel.xcdatamodel/contents | 5 ++-- .../Outline/ContinueWithView.swift | 6 +++-- .../CourseVerticalImageView.swift | 15 +++++++---- .../Presentation/Unit/CourseUnitView.swift | 12 ++++++--- .../Unit/CourseUnitViewModel.swift | 10 ++++++- .../DropdownList/CourseUnitDropDownCell.swift | 3 ++- .../DropdownList/CourseUnitDropDownList.swift | 12 ++++++--- .../CourseUnitVerticalsDropdownView.swift | 12 ++++++--- .../CourseContainerViewModelTests.swift | 27 ++++++++++++------- .../Unit/CourseUnitViewModelTests.swift | 12 ++++++--- OpenEdX/Data/CoursePersistence.swift | 4 ++- 15 files changed, 96 insertions(+), 41 deletions(-) diff --git a/Core/Core/Domain/Model/CourseBlockModel.swift b/Core/Core/Domain/Model/CourseBlockModel.swift index 24a74bcf4..9e3eb0b11 100644 --- a/Core/Core/Domain/Model/CourseBlockModel.swift +++ b/Core/Core/Domain/Model/CourseBlockModel.swift @@ -187,6 +187,7 @@ public struct CourseBlock: Hashable, Identifiable { public let studentUrl: String public let subtitles: [SubtitleUrl]? public let encodedVideo: CourseBlockEncodedVideo? + public let multiDevice: Bool? public var isDownloadable: Bool { encodedVideo?.isDownloadable ?? false @@ -203,7 +204,8 @@ public struct CourseBlock: Hashable, Identifiable { displayName: String, studentUrl: String, subtitles: [SubtitleUrl]? = nil, - encodedVideo: CourseBlockEncodedVideo? + encodedVideo: CourseBlockEncodedVideo?, + multiDevice: Bool? ) { self.blockId = blockId self.id = id @@ -216,6 +218,7 @@ public struct CourseBlock: Hashable, Identifiable { self.studentUrl = studentUrl self.subtitles = subtitles self.encodedVideo = encodedVideo + self.multiDevice = multiDevice } } diff --git a/Core/Core/Domain/Model/CourseDetailBlock.swift b/Core/Core/Domain/Model/CourseDetailBlock.swift index 8f2f9db73..31fb4f9f9 100644 --- a/Core/Core/Domain/Model/CourseDetailBlock.swift +++ b/Core/Core/Domain/Model/CourseDetailBlock.swift @@ -42,6 +42,8 @@ public enum BlockType: String { case survey case unknown case dragAndDropV2 = "drag-and-drop-v2" + case openassessment + case peerInstructionTool = "ubcpi" public var image: Image { switch self { diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index d5f540a0d..68765c1c5 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -208,7 +208,8 @@ public class CourseRepository: CourseRepositoryProtocol { mobileHigh: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.mobileHigh), mobileLow: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.mobileLow), hls: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.hls) - ) + ), + multiDevice: block.multiDevice ) } @@ -401,7 +402,8 @@ And there are various ways of describing it-- call it oral poetry or mobileHigh: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.mobileHigh), mobileLow: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.mobileLow), hls: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.hls) - ) + ), + multiDevice: block.multiDevice ) } diff --git a/Course/Course/Data/Model/Data_CourseOutlineResponse.swift b/Course/Course/Data/Model/Data_CourseOutlineResponse.swift index 302625837..f49380435 100644 --- a/Course/Course/Data/Model/Data_CourseOutlineResponse.swift +++ b/Course/Course/Data/Model/Data_CourseOutlineResponse.swift @@ -59,6 +59,7 @@ public extension DataLayer { public let descendants: [String]? public let allSources: [String]? public let userViewData: CourseDetailUserViewData? + public let multiDevice: Bool? public init( blockId: String, @@ -70,7 +71,8 @@ public extension DataLayer { displayName: String, descendants: [String]?, allSources: [String]?, - userViewData: CourseDetailUserViewData? + userViewData: CourseDetailUserViewData?, + multiDevice: Bool? ) { self.blockId = blockId self.id = id @@ -82,6 +84,7 @@ public extension DataLayer { self.descendants = descendants self.allSources = allSources self.userViewData = userViewData + self.multiDevice = multiDevice } public enum CodingKeys: String, CodingKey { @@ -91,6 +94,7 @@ public extension DataLayer { case displayName = "display_name" case userViewData = "student_view_data" case allSources = "all_sources" + case multiDevice = "student_view_multi_device" } } diff --git a/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents b/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents index 2ec79a638..8703670b3 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 @@ - + @@ -9,6 +9,7 @@ + @@ -96,4 +97,4 @@ - + \ No newline at end of file diff --git a/Course/Course/Presentation/Outline/ContinueWithView.swift b/Course/Course/Presentation/Outline/ContinueWithView.swift index e1b929728..94b8bbaf7 100644 --- a/Course/Course/Presentation/Outline/ContinueWithView.swift +++ b/Course/Course/Presentation/Outline/ContinueWithView.swift @@ -85,7 +85,8 @@ struct ContinueWithView_Previews: PreviewProvider { type: .html, displayName: "Continue lesson", studentUrl: "", - encodedVideo: nil + encodedVideo: nil, + multiDevice: true ), CourseBlock( blockId: "2", @@ -96,7 +97,8 @@ struct ContinueWithView_Previews: PreviewProvider { type: .html, displayName: "Continue lesson", studentUrl: "", - encodedVideo: nil + encodedVideo: nil, + multiDevice: false ) ] diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift index 7d6288fd7..c9f7ee07b 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift @@ -39,7 +39,8 @@ struct CourseVerticalImageView_Previews: PreviewProvider { type: .video, displayName: "Block 1", studentUrl: "", - encodedVideo: nil + encodedVideo: nil, + multiDevice: true ) ] @@ -54,7 +55,8 @@ struct CourseVerticalImageView_Previews: PreviewProvider { type: .problem, displayName: "Block 1", studentUrl: "", - encodedVideo: nil + encodedVideo: nil, + multiDevice: false ) ] let blocks3 = [ @@ -68,7 +70,8 @@ struct CourseVerticalImageView_Previews: PreviewProvider { type: .discussion, displayName: "Block 1", studentUrl: "", - encodedVideo: nil + encodedVideo: nil, + multiDevice: true ) ] let blocks4 = [ @@ -82,7 +85,8 @@ struct CourseVerticalImageView_Previews: PreviewProvider { type: .html, displayName: "Block 1", studentUrl: "", - encodedVideo: nil + encodedVideo: nil, + multiDevice: false ) ] let blocks5 = [ @@ -96,7 +100,8 @@ struct CourseVerticalImageView_Previews: PreviewProvider { type: .unknown, displayName: "Block 1", studentUrl: "", - encodedVideo: nil + encodedVideo: nil, + multiDevice: true ) ] HStack { diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index d75b96530..73b529d32 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -444,7 +444,8 @@ struct CourseUnitView_Previews: PreviewProvider { type: .video, displayName: "Lesson 1", studentUrl: "", - encodedVideo: nil + encodedVideo: nil, + multiDevice: true ), CourseBlock( blockId: "2", @@ -456,7 +457,8 @@ struct CourseUnitView_Previews: PreviewProvider { type: .video, displayName: "Lesson 2", studentUrl: "2", - encodedVideo: nil + encodedVideo: nil, + multiDevice: false ), CourseBlock( blockId: "3", @@ -468,7 +470,8 @@ struct CourseUnitView_Previews: PreviewProvider { type: .unknown, displayName: "Lesson 3", studentUrl: "3", - encodedVideo: nil + encodedVideo: nil, + multiDevice: true ), CourseBlock( blockId: "4", @@ -480,7 +483,8 @@ struct CourseUnitView_Previews: PreviewProvider { type: .unknown, displayName: "4", studentUrl: "4", - encodedVideo: nil + encodedVideo: nil, + multiDevice: false ), ] diff --git a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift index 6bde8a84d..586ef427a 100644 --- a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift +++ b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift @@ -18,8 +18,14 @@ public enum LessonType: Equatable { static func from(_ block: CourseBlock, streamingQuality: StreamingQuality) -> Self { let mandatoryInjections: [WebviewInjection] = [.colorInversionCss, .ajaxCallback] switch block.type { - case .course, .chapter, .vertical, .sequential, .unknown: + case .course, .chapter, .vertical, .sequential: return .unknown(block.studentUrl) + case .unknown: + if let multiDevice = block.multiDevice, multiDevice { + return .web(url: block.studentUrl, injections: mandatoryInjections) + } else { + return .unknown(block.studentUrl) + } case .html: return .web(url: block.studentUrl, injections: mandatoryInjections) case .discussion: @@ -41,6 +47,8 @@ public enum LessonType: Equatable { return .web(url: block.studentUrl, injections: mandatoryInjections + [.dragAndDropCss]) case .survey: return .web(url: block.studentUrl, injections: mandatoryInjections + [.surveyCSS]) + case .openassessment, .peerInstructionTool: + return .web(url: block.studentUrl, injections: mandatoryInjections) } } } diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift index 612c164be..c135ab817 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift @@ -81,7 +81,8 @@ struct CourseUnitDropDownCell_Previews: PreviewProvider { type: .video, displayName: "Lesson 1", studentUrl: "", - encodedVideo: nil + encodedVideo: nil, + multiDevice: true ) ] ) diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift index 60b0a501e..f48b7159c 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift @@ -54,7 +54,8 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { type: .video, displayName: "Lesson 1", studentUrl: "", - encodedVideo: nil + encodedVideo: nil, + multiDevice: true ), CourseBlock( blockId: "2", @@ -66,7 +67,8 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { type: .video, displayName: "Lesson 2", studentUrl: "2", - encodedVideo: nil + encodedVideo: nil, + multiDevice: false ), CourseBlock( blockId: "3", @@ -78,7 +80,8 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { type: .unknown, displayName: "Lesson 3", studentUrl: "3", - encodedVideo: nil + encodedVideo: nil, + multiDevice: true ), CourseBlock( blockId: "4", @@ -90,7 +93,8 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { type: .unknown, displayName: "4", studentUrl: "4", - encodedVideo: nil + encodedVideo: nil, + multiDevice: false ) ] diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift index 1bee39cc4..7a7703b09 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift @@ -67,7 +67,8 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { type: .video, displayName: "Lesson 1", studentUrl: "", - encodedVideo: nil + encodedVideo: nil, + multiDevice: true ), CourseBlock( @@ -80,7 +81,8 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { type: .video, displayName: "Lesson 2", studentUrl: "2", - encodedVideo: nil + encodedVideo: nil, + multiDevice: false ), CourseBlock( @@ -93,7 +95,8 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { type: .unknown, displayName: "Lesson 3", studentUrl: "3", - encodedVideo: nil + encodedVideo: nil, + multiDevice: true ), CourseBlock( @@ -106,7 +109,8 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { type: .unknown, displayName: "4", studentUrl: "4", - encodedVideo: nil + encodedVideo: nil, + multiDevice: false ) ] diff --git a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift index a62719c81..ddbcce7bf 100644 --- a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift @@ -52,7 +52,8 @@ final class CourseContainerViewModelTests: XCTestCase { type: .problem, displayName: "", studentUrl: "", - encodedVideo: nil + encodedVideo: nil, + multiDevice: true ) let vertical = CourseVertical( blockId: "", @@ -362,7 +363,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileHigh: nil, mobileLow: nil, hls: nil - ) + ), + multiDevice: true ) @@ -492,7 +494,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileHigh: nil, mobileLow: nil, hls: nil - ) + ), + multiDevice: true ) let vertical = CourseVertical( @@ -608,7 +611,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileHigh: nil, mobileLow: nil, hls: nil - ) + ), + multiDevice: true ) let vertical = CourseVertical( @@ -725,7 +729,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileHigh: nil, mobileLow: nil, hls: nil - ) + ), + multiDevice: true ) let vertical = CourseVertical( @@ -835,7 +840,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileHigh: nil, mobileLow: nil, hls: nil - ) + ), + multiDevice: true ) let vertical = CourseVertical( @@ -958,7 +964,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileHigh: nil, mobileLow: nil, hls: nil - ) + ), + multiDevice: true ) let vertical = CourseVertical( @@ -1080,7 +1087,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileHigh: nil, mobileLow: nil, hls: nil - ) + ), + multiDevice: true ) let block2 = CourseBlock( blockId: "123", @@ -1099,7 +1107,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileHigh: nil, mobileLow: nil, hls: nil - ) + ), + multiDevice: true ) let vertical = CourseVertical( diff --git a/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift index 332fbee8b..d953996eb 100644 --- a/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift @@ -25,7 +25,8 @@ final class CourseUnitViewModelTests: XCTestCase { type: .video, displayName: "Lesson 1", studentUrl: "", - encodedVideo: nil + encodedVideo: nil, + multiDevice: true ), CourseBlock(blockId: "2", id: "2", @@ -36,7 +37,8 @@ final class CourseUnitViewModelTests: XCTestCase { type: .video, displayName: "Lesson 2", studentUrl: "2", - encodedVideo: nil + encodedVideo: nil, + multiDevice: false ), CourseBlock(blockId: "3", id: "3", @@ -47,7 +49,8 @@ final class CourseUnitViewModelTests: XCTestCase { type: .unknown, displayName: "Lesson 3", studentUrl: "3", - encodedVideo: nil + encodedVideo: nil, + multiDevice: true ), CourseBlock(blockId: "4", id: "4", @@ -58,7 +61,8 @@ final class CourseUnitViewModelTests: XCTestCase { type: .unknown, displayName: "4", studentUrl: "4", - encodedVideo: nil + encodedVideo: nil, + multiDevice: false ), ] diff --git a/OpenEdX/Data/CoursePersistence.swift b/OpenEdX/Data/CoursePersistence.swift index 90d9470e6..ca9961d08 100644 --- a/OpenEdX/Data/CoursePersistence.swift +++ b/OpenEdX/Data/CoursePersistence.swift @@ -117,7 +117,8 @@ public class CoursePersistence: CoursePersistenceProtocol { displayName: $0.displayName ?? "", descendants: $0.descendants, allSources: $0.allSources, - userViewData: userViewData + userViewData: userViewData, + multiDevice: $0.multiDevice ) } @@ -162,6 +163,7 @@ public class CoursePersistence: CoursePersistenceProtocol { courseDetail.studentUrl = block.studentUrl courseDetail.type = block.type courseDetail.completion = block.completion ?? 0 + courseDetail.multiDevice = block.multiDevice ?? false if block.userViewData?.encodedVideo?.youTube != nil { let youTube = CDCourseBlockVideo(context: self.context) From 2bb24aa1fa19281393d22ee20e530ac47421589d Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Mon, 19 Feb 2024 20:39:23 +0500 Subject: [PATCH 017/136] refactor: fix typo --- Core/Core/View/Base/AlertView.swift | 6 +-- .../View/Base/LogistrationBottomView.swift | 4 +- Core/Core/View/Base/UnitButtonView.swift | 10 ++--- .../DeleteAccount/DeleteAccountView.swift | 2 +- .../Contents.json | 38 +++++++++++++++++++ .../Contents.json | 38 +++++++++++++++++++ Theme/Theme/SwiftGen/ThemeAssets.swift | 4 +- Theme/Theme/Theme.swift | 12 +++--- .../Elements/WhatsNewNavigationButton.swift | 6 +-- 9 files changed, 98 insertions(+), 22 deletions(-) create mode 100644 Theme/Theme/Assets.xcassets/Colors/SecondaryButtonBorderColor.colorset/Contents.json create mode 100644 Theme/Theme/Assets.xcassets/Colors/SecondaryButtonTextColor.colorset/Contents.json diff --git a/Core/Core/View/Base/AlertView.swift b/Core/Core/View/Base/AlertView.swift index 9418eeac2..80aa89307 100644 --- a/Core/Core/View/Base/AlertView.swift +++ b/Core/Core/View/Base/AlertView.swift @@ -339,7 +339,7 @@ public struct AlertView: View { lineJoin: .round, miterLimit: 1 )) - .foregroundColor(Theme.Colors.secondardButtonBorderColor) + .foregroundColor(Theme.Colors.secondaryButtonBorderColor) ) .frame(maxWidth: 215) } @@ -379,7 +379,7 @@ public struct AlertView: View { }, label: { ZStack { Text(CoreLocalization.Alert.cancel) - .foregroundColor(Theme.Colors.secondardButtonTextColor) + .foregroundColor(Theme.Colors.secondaryButtonTextColor) .font(Theme.Fonts.labelLarge) .frame(maxWidth: .infinity) .padding(.horizontal, 16) @@ -398,7 +398,7 @@ public struct AlertView: View { lineJoin: .round, miterLimit: 1 )) - .foregroundColor(Theme.Colors.secondardButtonBorderColor) + .foregroundColor(Theme.Colors.secondaryButtonBorderColor) ) .frame(maxWidth: 215) } diff --git a/Core/Core/View/Base/LogistrationBottomView.swift b/Core/Core/View/Base/LogistrationBottomView.swift index 3a0b88fbd..6795c62ea 100644 --- a/Core/Core/View/Base/LogistrationBottomView.swift +++ b/Core/Core/View/Base/LogistrationBottomView.swift @@ -46,8 +46,8 @@ public struct LogistrationBottomView: View { action(.signIn) }, color: Theme.Colors.white, - textColor: Theme.Colors.secondardButtonTextColor, - borderColor: Theme.Colors.secondardButtonBorderColor + textColor: Theme.Colors.secondaryButtonTextColor, + borderColor: Theme.Colors.secondaryButtonBorderColor ) .frame(width: 100) .accessibilityIdentifier("logistration_signin_button") diff --git a/Core/Core/View/Base/UnitButtonView.swift b/Core/Core/View/Base/UnitButtonView.swift index f447a05fe..5dfc96775 100644 --- a/Core/Core/View/Base/UnitButtonView.swift +++ b/Core/Core/View/Base/UnitButtonView.swift @@ -99,13 +99,13 @@ public struct UnitButtonView: View { HStack { if isVerticalNavigation { Text(type.stringValue()) - .foregroundColor(Theme.Colors.secondardButtonTextColor) + .foregroundColor(Theme.Colors.secondaryButtonTextColor) .font(Theme.Fonts.labelLarge) .padding(.leading, 20) CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) .rotationEffect(Angle.degrees(90)) .padding(.trailing, 20) - .foregroundColor(Theme.Colors.secondardButtonTextColor) + .foregroundColor(Theme.Colors.secondaryButtonTextColor) } else { CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) .padding(.leading, 20) @@ -139,7 +139,7 @@ public struct UnitButtonView: View { case .reload, .custom: VStack(alignment: .center) { Text(type.stringValue()) - .foregroundColor(bgColor == nil ? .white : Theme.Colors.secondardButtonTextColor) + .foregroundColor(bgColor == nil ? .white : Theme.Colors.secondaryButtonTextColor) .font(Theme.Fonts.labelLarge) }.padding(.horizontal, 16) case .continueLesson, .nextSection: @@ -174,7 +174,7 @@ public struct UnitButtonView: View { miterLimit: 1) ) .foregroundColor( - type == .previous ? Theme.Colors.secondardButtonBorderColor + type == .previous ? Theme.Colors.secondaryButtonBorderColor : Theme.Colors.accentButtonColor ) ) @@ -200,7 +200,7 @@ public struct UnitButtonView: View { )) .foregroundColor( type == .continueLesson ? Theme.Colors.accentButtonColor - : Theme.Colors.secondardButtonBorderColor + : Theme.Colors.secondaryButtonBorderColor ) ) } diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift index e3af3e76a..8e6048f87 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift @@ -121,7 +121,7 @@ public struct DeleteAccountView: View { .overlay( Theme.Shapes.buttonShape .stroke(style: .init(lineWidth: 1, lineCap: .round, lineJoin: .round, miterLimit: 1)) - .foregroundColor(Theme.Colors.secondardButtonBorderColor) + .foregroundColor(Theme.Colors.secondaryButtonBorderColor) ) .padding(.top, 35) diff --git a/Theme/Theme/Assets.xcassets/Colors/SecondaryButtonBorderColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/SecondaryButtonBorderColor.colorset/Contents.json new file mode 100644 index 000000000..00d59cb46 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/SecondaryButtonBorderColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.408", + "red" : "0.235" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.976", + "green" : "0.471", + "red" : "0.329" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/Colors/SecondaryButtonTextColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/SecondaryButtonTextColor.colorset/Contents.json new file mode 100644 index 000000000..00d59cb46 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/SecondaryButtonTextColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.408", + "red" : "0.235" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.976", + "green" : "0.471", + "red" : "0.329" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/SwiftGen/ThemeAssets.swift b/Theme/Theme/SwiftGen/ThemeAssets.swift index 7eb0fcb2e..132901ac5 100644 --- a/Theme/Theme/SwiftGen/ThemeAssets.swift +++ b/Theme/Theme/SwiftGen/ThemeAssets.swift @@ -38,8 +38,8 @@ public enum ThemeAssets { public static let commentCellBackground = ColorAsset(name: "CommentCellBackground") public static let loginBackground = ColorAsset(name: "LoginBackground") public static let loginNavigationText = ColorAsset(name: "LoginNavigationText") - public static let secondardButtonBorderColor = ColorAsset(name: "SecondardButtonBorderColor") - public static let secondardButtonTextColor = ColorAsset(name: "SecondardButtonTextColor") + public static let secondaryButtonBorderColor = ColorAsset(name: "SecondaryButtonBorderColor") + public static let secondaryButtonTextColor = ColorAsset(name: "SecondaryButtonTextColor") public static let shadowColor = ColorAsset(name: "ShadowColor") public static let snackbarErrorColor = ColorAsset(name: "SnackbarErrorColor") public static let snackbarInfoAlert = ColorAsset(name: "SnackbarInfoAlert") diff --git a/Theme/Theme/Theme.swift b/Theme/Theme/Theme.swift index d2cbbeae2..08fa293e1 100644 --- a/Theme/Theme/Theme.swift +++ b/Theme/Theme/Theme.swift @@ -52,8 +52,8 @@ public struct Theme { public private(set) static var datesSectionBackground = ThemeAssets.datesSectionBackground.swiftUIColor public private(set) static var datesSectionStroke = ThemeAssets.datesSectionStroke.swiftUIColor public private(set) static var navigationBarTintColor = ThemeAssets.navigationBarTintColor.swiftUIColor - public private(set) static var secondardButtonBorderColor = ThemeAssets.secondardButtonBorderColor.swiftUIColor - public private(set) static var secondardButtonTextColor = ThemeAssets.secondardButtonTextColor.swiftUIColor + public private(set) static var secondaryButtonBorderColor = ThemeAssets.secondaryButtonBorderColor.swiftUIColor + public private(set) static var secondaryButtonTextColor = ThemeAssets.secondaryButtonTextColor.swiftUIColor public static func update( accentColor: Color = ThemeAssets.accentColor.swiftUIColor, @@ -90,8 +90,8 @@ public struct Theme { datesSectionBackground: Color = ThemeAssets.datesSectionBackground.swiftUIColor, datesSectionStroke: Color = ThemeAssets.datesSectionStroke.swiftUIColor, navigationBarTintColor: Color = ThemeAssets.navigationBarTintColor.swiftUIColor, - secondardButtonBorderColor: Color = ThemeAssets.secondardButtonBorderColor.swiftUIColor, - secondardButtonTextColor: Color = ThemeAssets.secondardButtonTextColor.swiftUIColor + secondaryButtonBorderColor: Color = ThemeAssets.secondaryButtonBorderColor.swiftUIColor, + secondaryButtonTextColor: Color = ThemeAssets.secondaryButtonTextColor.swiftUIColor ) { self.accentColor = accentColor self.accentXColor = accentXColor @@ -127,8 +127,8 @@ public struct Theme { self.datesSectionBackground = datesSectionBackground self.datesSectionStroke = datesSectionStroke self.navigationBarTintColor = navigationBarTintColor - self.secondardButtonBorderColor = secondardButtonBorderColor - self.secondardButtonTextColor = secondardButtonTextColor + self.secondaryButtonBorderColor = secondaryButtonBorderColor + self.secondaryButtonTextColor = secondaryButtonTextColor } } diff --git a/WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift b/WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift index e6bcb19c2..640aa5545 100644 --- a/WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift +++ b/WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift @@ -23,12 +23,12 @@ struct WhatsNewNavigationButton: View { if type == .previous { CoreAssets.arrowLeft.swiftUIImage .renderingMode(.template) - .foregroundColor(Theme.Colors.secondardButtonTextColor) + .foregroundColor(Theme.Colors.secondaryButtonTextColor) } Text(type == .previous ? WhatsNewLocalization.buttonPrevious : (type == .next ? WhatsNewLocalization.buttonNext : WhatsNewLocalization.buttonDone )) - .foregroundColor(type == .previous ? Theme.Colors.secondardButtonTextColor : Theme.Colors.white) + .foregroundColor(type == .previous ? Theme.Colors.secondaryButtonTextColor : Theme.Colors.white) .font(Theme.Fonts.labelLarge) if type == .next { @@ -60,7 +60,7 @@ struct WhatsNewNavigationButton: View { .overlay( Theme.Shapes.buttonShape .stroke(type == .previous - ? Theme.Colors.secondardButtonBorderColor + ? Theme.Colors.secondaryButtonBorderColor : Theme.Colors.background, lineWidth: 1) ) .onTapGesture { action() } From f4d84c44b2f081c5cf0b76aa7c5bc291566fc642 Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Mon, 19 Feb 2024 20:39:42 +0500 Subject: [PATCH 018/136] refactor: button renaming --- .../Contents.json | 38 ------------------- .../Contents.json | 38 ------------------- 2 files changed, 76 deletions(-) delete mode 100644 Theme/Theme/Assets.xcassets/Colors/SecondardButtonBorderColor.colorset/Contents.json delete mode 100644 Theme/Theme/Assets.xcassets/Colors/SecondardButtonTextColor.colorset/Contents.json diff --git a/Theme/Theme/Assets.xcassets/Colors/SecondardButtonBorderColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/SecondardButtonBorderColor.colorset/Contents.json deleted file mode 100644 index 00d59cb46..000000000 --- a/Theme/Theme/Assets.xcassets/Colors/SecondardButtonBorderColor.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "1.000", - "green" : "0.408", - "red" : "0.235" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.976", - "green" : "0.471", - "red" : "0.329" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Theme/Theme/Assets.xcassets/Colors/SecondardButtonTextColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/SecondardButtonTextColor.colorset/Contents.json deleted file mode 100644 index 00d59cb46..000000000 --- a/Theme/Theme/Assets.xcassets/Colors/SecondardButtonTextColor.colorset/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "colors" : [ - { - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "1.000", - "green" : "0.408", - "red" : "0.235" - } - }, - "idiom" : "universal" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "color" : { - "color-space" : "srgb", - "components" : { - "alpha" : "1.000", - "blue" : "0.976", - "green" : "0.471", - "red" : "0.329" - } - }, - "idiom" : "universal" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} From 59b5be7d209b41c3a3e8aeff728482e2a417b46c Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Mon, 19 Feb 2024 18:53:20 +0100 Subject: [PATCH 019/136] chore: added FirebaseManager to setup standalone Firebase in separate file --- Core/Core.xcodeproj/project.pbxproj | 25 ---------------- .../Configuration/Config/FirebaseConfig.swift | 21 -------------- .../DiscussionTopicsViewModel.swift | 1 - OpenEdX.xcodeproj/project.pbxproj | 29 +++++++++++++++++++ OpenEdX/AppDelegate.swift | 12 ++++---- OpenEdX/Info.plist | 2 ++ .../AnalyticsManager/AnalyticsManager.swift | 6 ++-- .../FirebaseManager/FirebaseManager.swift | 15 ++++++++++ Podfile.lock | 2 +- 9 files changed, 56 insertions(+), 57 deletions(-) create mode 100644 OpenEdX/Managers/FirebaseManager/FirebaseManager.swift diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index ac3e61e30..c0c92ac6d 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -126,8 +126,6 @@ 07DDFCBD29A780BB00572595 /* UINavigationController+Animation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07DDFCBC29A780BB00572595 /* UINavigationController+Animation.swift */; }; 07E0939F2B308D2800F1E4B2 /* Data_Certificate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E0939E2B308D2800F1E4B2 /* Data_Certificate.swift */; }; 141F1D302B7328D4009E81EB /* WebviewCookiesUpdateProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 141F1D2F2B7328D4009E81EB /* WebviewCookiesUpdateProtocol.swift */; }; - A51188632B729647004E9F8E /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = A51188622B729647004E9F8E /* FirebaseAnalytics */; }; - A51188652B72964F004E9F8E /* FirebaseCrashlytics in Frameworks */ = {isa = PBXBuildFile; productRef = A51188642B72964F004E9F8E /* FirebaseCrashlytics */; }; A51CDBE72B6D21F2009B6D4E /* SegmentConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51CDBE62B6D21F2009B6D4E /* SegmentConfig.swift */; }; A53A32352B233DEC005FE38A /* ThemeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A32342B233DEC005FE38A /* ThemeConfig.swift */; }; A595689B2B6173DF00ED4F90 /* BranchConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A595689A2B6173DF00ED4F90 /* BranchConfig.swift */; }; @@ -358,8 +356,6 @@ files = ( BAF0D4CB2AD6AE14007AC334 /* FacebookLogin in Frameworks */, 025EF2F62971740000B838AB /* YouTubePlayerKit in Frameworks */, - A51188632B729647004E9F8E /* FirebaseAnalytics in Frameworks */, - A51188652B72964F004E9F8E /* FirebaseCrashlytics in Frameworks */, C8C446EF233F81B9FABB77D2 /* Pods_App_Core.framework in Frameworks */, BA8FA66C2AD59BBC00EA029A /* GoogleSignIn in Frameworks */, E055A5392B18DC95008D9E5E /* Theme.framework in Frameworks */, @@ -877,8 +873,6 @@ 025EF2F52971740000B838AB /* YouTubePlayerKit */, BA8FA66B2AD59BBC00EA029A /* GoogleSignIn */, BAF0D4CA2AD6AE14007AC334 /* FacebookLogin */, - A51188622B729647004E9F8E /* FirebaseAnalytics */, - A51188642B72964F004E9F8E /* FirebaseCrashlytics */, ); productName = Core; productReference = 0770DE0828D07831006D8A5D /* Core.framework */; @@ -918,7 +912,6 @@ 025EF2F42971740000B838AB /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */, BA8FA65E2AD574D700EA029A /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */, BA8FA6712AD6ABA300EA029A /* XCRemoteSwiftPackageReference "facebook-ios-sdk" */, - A51188612B7295F7004E9F8E /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); productRefGroup = 0770DE0928D07831006D8A5D /* Products */; projectDirPath = ""; @@ -2175,14 +2168,6 @@ minimumVersion = 1.5.0; }; }; - A51188612B7295F7004E9F8E /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 10.20.0; - }; - }; BA8FA65E2AD574D700EA029A /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/google/GoogleSignIn-iOS"; @@ -2207,16 +2192,6 @@ package = 025EF2F42971740000B838AB /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */; productName = YouTubePlayerKit; }; - A51188622B729647004E9F8E /* FirebaseAnalytics */ = { - isa = XCSwiftPackageProductDependency; - package = A51188612B7295F7004E9F8E /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseAnalytics; - }; - A51188642B72964F004E9F8E /* FirebaseCrashlytics */ = { - isa = XCSwiftPackageProductDependency; - package = A51188612B7295F7004E9F8E /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseCrashlytics; - }; BA8FA66B2AD59BBC00EA029A /* GoogleSignIn */ = { isa = XCSwiftPackageProductDependency; package = BA8FA65E2AD574D700EA029A /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */; diff --git a/Core/Core/Configuration/Config/FirebaseConfig.swift b/Core/Core/Configuration/Config/FirebaseConfig.swift index e8180f574..d70de04ab 100644 --- a/Core/Core/Configuration/Config/FirebaseConfig.swift +++ b/Core/Core/Configuration/Config/FirebaseConfig.swift @@ -6,7 +6,6 @@ // import Foundation -import FirebaseCore private enum FirebaseKeys: String { case enabled = "ENABLED" @@ -76,26 +75,6 @@ public final class FirebaseConfig: NSObject { public var isAnalyticsSourceFirebase: Bool { return analyticsSource == AnalyticsSource.firebase } - - public var firebaseOptions: FirebaseOptions? { - if enabled, - requiredKeysAvailable, - let bundleID = bundleID, - let googleAppID = googleAppID, - let gcmSenderID = gcmSenderID { - let firebaseOptions = FirebaseOptions(googleAppID: googleAppID, - gcmSenderID: gcmSenderID) - firebaseOptions.apiKey = apiKey - firebaseOptions.projectID = projectID - firebaseOptions.bundleID = bundleID - firebaseOptions.clientID = clientID - firebaseOptions.storageBucket = storageBucket - firebaseOptions.databaseURL = databaseURL - return firebaseOptions - } - - return nil - } } private let firebaseKey = "FIREBASE" diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift index 85ec229e8..b14271f3e 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift @@ -8,7 +8,6 @@ import Foundation import SwiftUI import Core -import FirebaseCrashlytics public class DiscussionTopicsViewModel: ObservableObject { diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 616ad4b81..e0e109bcb 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -59,6 +59,8 @@ A59568972B61653700ED4F90 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568962B61653700ED4F90 /* DeepLink.swift */; }; A59568992B616D9400ED4F90 /* PushLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568982B616D9400ED4F90 /* PushLink.swift */; }; A59585AF2B62A07100A35A20 /* BranchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59585AE2B62A07100A35A20 /* BranchService.swift */; }; + A59702292B83C87900CA064C /* FirebaseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59702282B83C87900CA064C /* FirebaseManager.swift */; }; + A5BD3E302B83B0F7006A8983 /* SegmentFirebase in Frameworks */ = {isa = PBXBuildFile; productRef = A5BD3E2F2B83B0F7006A8983 /* SegmentFirebase */; }; BA3042792B1F7147009B64B7 /* MSAL in Frameworks */ = {isa = PBXBuildFile; productRef = BA3042782B1F7147009B64B7 /* MSAL */; }; E0D6E6A32B1626B10089F9C9 /* Theme.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0D6E6A22B1626B10089F9C9 /* Theme.framework */; }; E0D6E6A42B1626D60089F9C9 /* Theme.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E0D6E6A22B1626B10089F9C9 /* Theme.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -140,6 +142,7 @@ A59568962B61653700ED4F90 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; A59568982B616D9400ED4F90 /* PushLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushLink.swift; sourceTree = ""; }; A59585AE2B62A07100A35A20 /* BranchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BranchService.swift; path = ../BranchService.swift; sourceTree = ""; }; + A59702282B83C87900CA064C /* FirebaseManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseManager.swift; sourceTree = ""; }; A89AD827F52CF6A6B903606E /* Pods-App-OpenEdX.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasestage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasestage.xcconfig"; sourceTree = ""; }; BB08ACD2CCA33D6DDDDD31B4 /* Pods-App-OpenEdX.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasedev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasedev.xcconfig"; sourceTree = ""; }; E0D6E6A22B1626B10089F9C9 /* Theme.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Theme.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -162,6 +165,7 @@ 0218196428F734FA00202564 /* Discussion.framework in Frameworks */, 0219C67728F4347600D64452 /* Course.framework in Frameworks */, 027DB33028D8A063002B6862 /* Dashboard.framework in Frameworks */, + A5BD3E302B83B0F7006A8983 /* SegmentFirebase in Frameworks */, 95C140F3BDF778364986E83B /* Pods_App_OpenEdX.framework in Frameworks */, A51CDBED2B6D2BEE009B6D4E /* SegmentBraze in Frameworks */, A51CDBEF2B6D2BEE009B6D4E /* SegmentBrazeUI in Frameworks */, @@ -291,6 +295,7 @@ A59568932B6162E400ED4F90 /* DeepLinkManager */, A50066872B613E4B0024680B /* PushNotificationsManager */, A50066892B613E990024680B /* AnalyticsManager */, + A59702272B83C84800CA064C /* FirebaseManager */, ); path = Managers; sourceTree = ""; @@ -351,6 +356,14 @@ path = Link; sourceTree = ""; }; + A59702272B83C84800CA064C /* FirebaseManager */ = { + isa = PBXGroup; + children = ( + A59702282B83C87900CA064C /* FirebaseManager.swift */, + ); + path = FirebaseManager; + sourceTree = ""; + }; A5F46FD02B692B140003EEEF /* Services */ = { isa = PBXGroup; children = ( @@ -385,6 +398,7 @@ A51CDBE42B6D1E93009B6D4E /* Segment */, A51CDBEC2B6D2BEE009B6D4E /* SegmentBraze */, A51CDBEE2B6D2BEE009B6D4E /* SegmentBrazeUI */, + A5BD3E2F2B83B0F7006A8983 /* SegmentFirebase */, ); productName = OpenEdX; productReference = 07D5DA3128D075AA00752FD9 /* OpenEdX.app */; @@ -419,6 +433,7 @@ BA3042772B1F7147009B64B7 /* XCRemoteSwiftPackageReference "microsoft-authentication-library-for-objc" */, A51CDBE32B6D1E93009B6D4E /* XCRemoteSwiftPackageReference "analytics-swift" */, A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */, + A5BD3E2E2B83B0F7006A8983 /* XCRemoteSwiftPackageReference "analytics-swift-firebase" */, ); productRefGroup = 07D5DA3228D075AA00752FD9 /* Products */; projectDirPath = ""; @@ -550,6 +565,7 @@ 0293A2032A6FCA590090A336 /* CorePersistence.swift in Sources */, 0770DE1E28D084E8006D8A5D /* AppAssembly.swift in Sources */, A50066932B614DCD0024680B /* FCMListener.swift in Sources */, + A59702292B83C87900CA064C /* FirebaseManager.swift in Sources */, A500668D2B6143000024680B /* FCMProvider.swift in Sources */, 025AD4AC2A6FB95C00AB8FA7 /* DatabaseManager.swift in Sources */, 024E69202AEFC3FB00FA0B59 /* MainScreenViewModel.swift in Sources */, @@ -1199,6 +1215,14 @@ minimumVersion = 2.2.0; }; }; + A5BD3E2E2B83B0F7006A8983 /* XCRemoteSwiftPackageReference "analytics-swift-firebase" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/segment-integrations/analytics-swift-firebase"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.3.5; + }; + }; BA3042772B1F7147009B64B7 /* XCRemoteSwiftPackageReference "microsoft-authentication-library-for-objc" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/AzureAD/microsoft-authentication-library-for-objc"; @@ -1225,6 +1249,11 @@ package = A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */; productName = SegmentBrazeUI; }; + A5BD3E2F2B83B0F7006A8983 /* SegmentFirebase */ = { + isa = XCSwiftPackageProductDependency; + package = A5BD3E2E2B83B0F7006A8983 /* XCRemoteSwiftPackageReference "analytics-swift-firebase" */; + productName = SegmentFirebase; + }; BA3042782B1F7147009B64B7 /* MSAL */ = { isa = XCSwiftPackageProductDependency; package = BA3042772B1F7147009B64B7 /* XCRemoteSwiftPackageReference "microsoft-authentication-library-for-objc" */; diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index 6f7ee65f4..e6c45d825 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -8,14 +8,13 @@ import UIKit import Core import Swinject -import FirebaseCore -import FirebaseCrashlytics import Profile import GoogleSignIn import FacebookCore import MSAL import Theme import Segment +import SegmentFirebase @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { @@ -37,12 +36,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { initDI() - if let config = Container.shared.resolve(ConfigProtocol.self) { Theme.Shapes.isRoundedCorners = config.theme.isRoundedCorners - if let configuration = config.firebase.firebaseOptions { - FirebaseApp.configure(options: configuration) - Crashlytics.crashlytics().setCrashlyticsCollectionEnabled(true) + if config.firebase.isAnalyticsSourceFirebase { + FirebaseManager.setup() } if config.facebook.enabled { ApplicationDelegate.shared.application( @@ -56,6 +53,9 @@ class AppDelegate: UIResponder, UIApplicationDelegate { .trackApplicationLifecycleEvents(true) .flushInterval(10) analytics = Analytics(configuration: configuration) + if config.firebase.isAnalyticsSourceSegment { + analytics?.add(plugin: FirebaseDestination()) + } } } diff --git a/OpenEdX/Info.plist b/OpenEdX/Info.plist index b74f3967c..dfb2fae04 100644 --- a/OpenEdX/Info.plist +++ b/OpenEdX/Info.plist @@ -29,5 +29,7 @@ UIViewControllerBasedStatusBarAppearance + FirebaseCrashlyticsCollectionEnabled + diff --git a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift index e4354d18a..7756c3b7d 100644 --- a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift +++ b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift @@ -36,12 +36,12 @@ class AnalyticsManager: AuthorizationAnalytics, private func servicesFor(config: ConfigProtocol) -> [AnalyticsService] { var analyticsServices: [AnalyticsService] = [] - // add Firebase Analytics Service if enabled and have config - if config.firebase.firebaseOptions != nil { + // add Firebase Analytics Service if enabled + if config.firebase.isAnalyticsSourceFirebase { analyticsServices.append(FirebaseAnalyticsService()) } // add Segment Analytics Service if enabled - if config.segment.enabled { + if config.firebase.isAnalyticsSourceSegment { analyticsServices.append(SegmentAnalyticsService()) } return analyticsServices diff --git a/OpenEdX/Managers/FirebaseManager/FirebaseManager.swift b/OpenEdX/Managers/FirebaseManager/FirebaseManager.swift new file mode 100644 index 000000000..4f5c4cf2b --- /dev/null +++ b/OpenEdX/Managers/FirebaseManager/FirebaseManager.swift @@ -0,0 +1,15 @@ +// +// FirebaseManager.swift +// OpenEdX +// +// Created by Anton Yarmolenka on 19/02/2024. +// + +import Foundation +import Firebase + +class FirebaseManager { + class func setup() { + FirebaseApp.configure() + } +} diff --git a/Podfile.lock b/Podfile.lock index efae391c2..f3f760cf7 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -54,6 +54,6 @@ SPEC CHECKSUMS: SwiftyMocky: c5e96e4ff76ec6dbf5a5941aeb039b5a546954a0 Swinject: 893c9a543000ac2f10ee4cbaf0933c6992c935d5 -PODFILE CHECKSUM: b4e49ed566507f9bbf9bcff18accdca28f28e106 +PODFILE CHECKSUM: 881176d00eabfe8f78d6022c56c277cf61aad22b COCOAPODS: 1.15.0 From 78a4f3d4bf04ef0ee2364fd2286c515cf412c223 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Wed, 21 Feb 2024 13:17:01 +0100 Subject: [PATCH 020/136] chore: moved analytics segment var into SegmentManager --- OpenEdX.xcodeproj/project.pbxproj | 12 +++++++++ OpenEdX/AppDelegate.swift | 18 ++++++------- OpenEdX/DI/AppAssembly.swift | 4 +++ .../Services/SegmentAnalyticsService.swift | 11 +++++--- .../Providers/BrazeProvider.swift | 4 ++- .../SegmentManager/SegmentManager.swift | 25 +++++++++++++++++++ 6 files changed, 59 insertions(+), 15 deletions(-) create mode 100644 OpenEdX/Managers/SegmentManager/SegmentManager.swift diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index e0e109bcb..e69934396 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -61,6 +61,7 @@ A59585AF2B62A07100A35A20 /* BranchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59585AE2B62A07100A35A20 /* BranchService.swift */; }; A59702292B83C87900CA064C /* FirebaseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59702282B83C87900CA064C /* FirebaseManager.swift */; }; A5BD3E302B83B0F7006A8983 /* SegmentFirebase in Frameworks */ = {isa = PBXBuildFile; productRef = A5BD3E2F2B83B0F7006A8983 /* SegmentFirebase */; }; + A5C10D8F2B861A70008E864D /* SegmentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C10D8E2B861A70008E864D /* SegmentManager.swift */; }; BA3042792B1F7147009B64B7 /* MSAL in Frameworks */ = {isa = PBXBuildFile; productRef = BA3042782B1F7147009B64B7 /* MSAL */; }; E0D6E6A32B1626B10089F9C9 /* Theme.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0D6E6A22B1626B10089F9C9 /* Theme.framework */; }; E0D6E6A42B1626D60089F9C9 /* Theme.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E0D6E6A22B1626B10089F9C9 /* Theme.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -143,6 +144,7 @@ A59568982B616D9400ED4F90 /* PushLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushLink.swift; sourceTree = ""; }; A59585AE2B62A07100A35A20 /* BranchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BranchService.swift; path = ../BranchService.swift; sourceTree = ""; }; A59702282B83C87900CA064C /* FirebaseManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseManager.swift; sourceTree = ""; }; + A5C10D8E2B861A70008E864D /* SegmentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentManager.swift; sourceTree = ""; }; A89AD827F52CF6A6B903606E /* Pods-App-OpenEdX.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasestage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasestage.xcconfig"; sourceTree = ""; }; BB08ACD2CCA33D6DDDDD31B4 /* Pods-App-OpenEdX.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasedev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasedev.xcconfig"; sourceTree = ""; }; E0D6E6A22B1626B10089F9C9 /* Theme.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Theme.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -296,6 +298,7 @@ A50066872B613E4B0024680B /* PushNotificationsManager */, A50066892B613E990024680B /* AnalyticsManager */, A59702272B83C84800CA064C /* FirebaseManager */, + A5C10D8D2B861A56008E864D /* SegmentManager */, ); path = Managers; sourceTree = ""; @@ -364,6 +367,14 @@ path = FirebaseManager; sourceTree = ""; }; + A5C10D8D2B861A56008E864D /* SegmentManager */ = { + isa = PBXGroup; + children = ( + A5C10D8E2B861A70008E864D /* SegmentManager.swift */, + ); + path = SegmentManager; + sourceTree = ""; + }; A5F46FD02B692B140003EEEF /* Services */ = { isa = PBXGroup; children = ( @@ -576,6 +587,7 @@ A50066952B614DEF0024680B /* BrazeListener.swift in Sources */, 071009C928D1DB3F00344290 /* ScreenAssembly.swift in Sources */, A59568972B61653700ED4F90 /* DeepLink.swift in Sources */, + A5C10D8F2B861A70008E864D /* SegmentManager.swift in Sources */, A59568992B616D9400ED4F90 /* PushLink.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index e6c45d825..965d81b41 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -13,8 +13,6 @@ import GoogleSignIn import FacebookCore import MSAL import Theme -import Segment -import SegmentFirebase @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { @@ -22,8 +20,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { static var shared: AppDelegate { UIApplication.shared.delegate as! AppDelegate } - - var analytics: Analytics? var window: UIWindow? @@ -49,13 +45,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { } configureDeepLinkServices(launchOptions: launchOptions) if config.segment.enabled { - let configuration = Configuration(writeKey: config.segment.writeKey) - .trackApplicationLifecycleEvents(true) - .flushInterval(10) - analytics = Analytics(configuration: configuration) - if config.firebase.isAnalyticsSourceSegment { - analytics?.add(plugin: FirebaseDestination()) - } + configureSegment(config) } } @@ -179,4 +169,10 @@ class AppDelegate: UIResponder, UIApplicationDelegate { guard let deepLinkManager = Container.shared.resolve(DeepLinkManager.self) else { return } deepLinkManager.configureDeepLinkService(launchOptions: launchOptions) } + + // Segment + func configureSegment(_ config: ConfigProtocol) { + guard let segmentManager = Container.shared.resolve(SegmentManager.self) else { return } + segmentManager.setup(with: config) + } } diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index e2ebd1479..850bf8b17 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -175,6 +175,10 @@ class AppAssembly: Assembly { config: r.resolve(ConfigProtocol.self)! ) }.inObjectScope(.container) + + container.register(SegmentManager.self) { r in + SegmentManager() + }.inObjectScope(.container) } } // swiftlint:enable function_body_length diff --git a/OpenEdX/Managers/AnalyticsManager/Services/SegmentAnalyticsService.swift b/OpenEdX/Managers/AnalyticsManager/Services/SegmentAnalyticsService.swift index 5515e5bc6..764d675fb 100644 --- a/OpenEdX/Managers/AnalyticsManager/Services/SegmentAnalyticsService.swift +++ b/OpenEdX/Managers/AnalyticsManager/Services/SegmentAnalyticsService.swift @@ -7,18 +7,23 @@ import Foundation import UIKit +import Swinject class SegmentAnalyticsService: AnalyticsService { func identify(id: String, username: String?, email: String?) { - guard let email = email, let username = username else { return } + guard let email = email, + let username = username, + let segmentManager = Container.shared.resolve(SegmentManager.self) + else { return } let traits: [String: String] = [ "email": email, "username": username ] - (UIApplication.shared.delegate as? AppDelegate)?.analytics?.identify(userId: id, traits: traits) + segmentManager.analytics?.identify(userId: id, traits: traits) } func logEvent(_ event: Event, parameters: [String: Any]?) { - (UIApplication.shared.delegate as? AppDelegate)?.analytics?.track( + guard let segmentManager = Container.shared.resolve(SegmentManager.self) else { return } + segmentManager.analytics?.track( name: event.rawValue, properties: parameters ) diff --git a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift index b086af806..dfb32a3e2 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift @@ -8,10 +8,12 @@ import Foundation import UIKit import SegmentBrazeUI +import Swinject class BrazeProvider: PushNotificationsProvider { func didRegisterWithDeviceToken(deviceToken: Data) { - (UIApplication.shared.delegate as? AppDelegate)?.analytics?.add( + guard let segmentManager = Container.shared.resolve(SegmentManager.self) else { return } + segmentManager.analytics?.add( plugin: BrazeDestination( additionalConfiguration: { configuration in configuration.logger.level = .debug diff --git a/OpenEdX/Managers/SegmentManager/SegmentManager.swift b/OpenEdX/Managers/SegmentManager/SegmentManager.swift new file mode 100644 index 000000000..70daa50c8 --- /dev/null +++ b/OpenEdX/Managers/SegmentManager/SegmentManager.swift @@ -0,0 +1,25 @@ +// +// SegmentManager.swift +// OpenEdX +// +// Created by Anton Yarmolenka on 21/02/2024. +// + +import Foundation +import Core +import Segment +import SegmentFirebase + +class SegmentManager { + var analytics: Analytics? + + public func setup(with config: ConfigProtocol) { + let configuration = Configuration(writeKey: config.segment.writeKey) + .trackApplicationLifecycleEvents(true) + .flushInterval(10) + analytics = Analytics(configuration: configuration) + if config.firebase.isAnalyticsSourceSegment { + analytics?.add(plugin: FirebaseDestination()) + } + } +} From 81984f916a5fc1cd10c5bde26727587584347990 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Wed, 21 Feb 2024 13:32:50 +0100 Subject: [PATCH 021/136] chore: moved AnalyticsService protocol implementation into Firebase and Segment managers --- OpenEdX.xcodeproj/project.pbxproj | 16 ---------- .../AnalyticsManager/AnalyticsManager.swift | 8 +++-- .../Services/FirebaseAnalyticsService.swift | 18 ----------- .../Services/SegmentAnalyticsService.swift | 31 ------------------- .../FirebaseManager/FirebaseManager.swift | 11 ++++++- .../SegmentManager/SegmentManager.swift | 18 ++++++++++- 6 files changed, 32 insertions(+), 70 deletions(-) delete mode 100644 OpenEdX/Managers/AnalyticsManager/Services/FirebaseAnalyticsService.swift delete mode 100644 OpenEdX/Managers/AnalyticsManager/Services/SegmentAnalyticsService.swift diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index e69934396..5f4a3c339 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -53,8 +53,6 @@ A51CDBE52B6D1E93009B6D4E /* Segment in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBE42B6D1E93009B6D4E /* Segment */; }; A51CDBED2B6D2BEE009B6D4E /* SegmentBraze in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBEC2B6D2BEE009B6D4E /* SegmentBraze */; }; A51CDBEF2B6D2BEE009B6D4E /* SegmentBrazeUI in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBEE2B6D2BEE009B6D4E /* SegmentBrazeUI */; }; - A52508082B7E4FCC0078E1D8 /* FirebaseAnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A52508072B7E4FCC0078E1D8 /* FirebaseAnalyticsService.swift */; }; - A525080A2B7E500A0078E1D8 /* SegmentAnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A52508092B7E500A0078E1D8 /* SegmentAnalyticsService.swift */; }; A59568952B61630500ED4F90 /* DeepLinkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568942B61630500ED4F90 /* DeepLinkManager.swift */; }; A59568972B61653700ED4F90 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568962B61653700ED4F90 /* DeepLink.swift */; }; A59568992B616D9400ED4F90 /* PushLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568982B616D9400ED4F90 /* PushLink.swift */; }; @@ -137,8 +135,6 @@ A50066902B61467B0024680B /* BrazeProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrazeProvider.swift; sourceTree = ""; }; A50066922B614DCD0024680B /* FCMListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FCMListener.swift; sourceTree = ""; }; A50066942B614DEF0024680B /* BrazeListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrazeListener.swift; sourceTree = ""; }; - A52508072B7E4FCC0078E1D8 /* FirebaseAnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseAnalyticsService.swift; sourceTree = ""; }; - A52508092B7E500A0078E1D8 /* SegmentAnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentAnalyticsService.swift; sourceTree = ""; }; A59568942B61630500ED4F90 /* DeepLinkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkManager.swift; sourceTree = ""; }; A59568962B61653700ED4F90 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; A59568982B616D9400ED4F90 /* PushLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushLink.swift; sourceTree = ""; }; @@ -307,7 +303,6 @@ isa = PBXGroup; children = ( 0298DF2F2A4EF7230023A257 /* AnalyticsManager.swift */, - A52508062B7E4FA90078E1D8 /* Services */, 02F175302A4DA95B0019CD70 /* MainScreenAnalytics.swift */, ); path = AnalyticsManager; @@ -331,15 +326,6 @@ path = Listeners; sourceTree = ""; }; - A52508062B7E4FA90078E1D8 /* Services */ = { - isa = PBXGroup; - children = ( - A52508072B7E4FCC0078E1D8 /* FirebaseAnalyticsService.swift */, - A52508092B7E500A0078E1D8 /* SegmentAnalyticsService.swift */, - ); - path = Services; - sourceTree = ""; - }; A59568932B6162E400ED4F90 /* DeepLinkManager */ = { isa = PBXGroup; children = ( @@ -565,8 +551,6 @@ 020CA5D92AA0A25300970AAF /* AppStorage.swift in Sources */, 0298DF302A4EF7230023A257 /* AnalyticsManager.swift in Sources */, A59585AF2B62A07100A35A20 /* BranchService.swift in Sources */, - A525080A2B7E500A0078E1D8 /* SegmentAnalyticsService.swift in Sources */, - A52508082B7E4FCC0078E1D8 /* FirebaseAnalyticsService.swift in Sources */, A50066912B61467B0024680B /* BrazeProvider.swift in Sources */, 0293A2072A6FCDA30090A336 /* DiscoveryPersistence.swift in Sources */, 07D5DA3528D075AA00752FD9 /* AppDelegate.swift in Sources */, diff --git a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift index 7756c3b7d..0928c10ad 100644 --- a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift +++ b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift @@ -13,6 +13,7 @@ import Dashboard import Profile import Course import Discussion +import Swinject protocol AnalyticsService { func identify(id: String, username: String?, email: String?) @@ -38,11 +39,12 @@ class AnalyticsManager: AuthorizationAnalytics, var analyticsServices: [AnalyticsService] = [] // add Firebase Analytics Service if enabled if config.firebase.isAnalyticsSourceFirebase { - analyticsServices.append(FirebaseAnalyticsService()) + analyticsServices.append(FirebaseManager()) } // add Segment Analytics Service if enabled - if config.firebase.isAnalyticsSourceSegment { - analyticsServices.append(SegmentAnalyticsService()) + if config.firebase.isAnalyticsSourceSegment, + let segmentManager = Container.shared.resolve(SegmentManager.self) { + analyticsServices.append(segmentManager) } return analyticsServices } diff --git a/OpenEdX/Managers/AnalyticsManager/Services/FirebaseAnalyticsService.swift b/OpenEdX/Managers/AnalyticsManager/Services/FirebaseAnalyticsService.swift deleted file mode 100644 index 271591360..000000000 --- a/OpenEdX/Managers/AnalyticsManager/Services/FirebaseAnalyticsService.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// GoogleAnalyticsService.swift -// OpenEdX -// -// Created by Anton Yarmolenka on 15/02/2024. -// - -import Foundation -import FirebaseAnalytics - -class FirebaseAnalyticsService: AnalyticsService { - func identify(id: String, username: String?, email: String?) { - Analytics.setUserID(id) - } - func logEvent(_ event: Event, parameters: [String: Any]?) { - Analytics.logEvent(event.rawValue, parameters: parameters) - } -} diff --git a/OpenEdX/Managers/AnalyticsManager/Services/SegmentAnalyticsService.swift b/OpenEdX/Managers/AnalyticsManager/Services/SegmentAnalyticsService.swift deleted file mode 100644 index 764d675fb..000000000 --- a/OpenEdX/Managers/AnalyticsManager/Services/SegmentAnalyticsService.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// SegmentAnalyticsService.swift -// OpenEdX -// -// Created by Anton Yarmolenka on 15/02/2024. -// - -import Foundation -import UIKit -import Swinject - -class SegmentAnalyticsService: AnalyticsService { - func identify(id: String, username: String?, email: String?) { - guard let email = email, - let username = username, - let segmentManager = Container.shared.resolve(SegmentManager.self) - else { return } - let traits: [String: String] = [ - "email": email, - "username": username - ] - segmentManager.analytics?.identify(userId: id, traits: traits) - } - func logEvent(_ event: Event, parameters: [String: Any]?) { - guard let segmentManager = Container.shared.resolve(SegmentManager.self) else { return } - segmentManager.analytics?.track( - name: event.rawValue, - properties: parameters - ) - } -} diff --git a/OpenEdX/Managers/FirebaseManager/FirebaseManager.swift b/OpenEdX/Managers/FirebaseManager/FirebaseManager.swift index 4f5c4cf2b..2e44ef373 100644 --- a/OpenEdX/Managers/FirebaseManager/FirebaseManager.swift +++ b/OpenEdX/Managers/FirebaseManager/FirebaseManager.swift @@ -8,8 +8,17 @@ import Foundation import Firebase -class FirebaseManager { +class FirebaseManager: AnalyticsService { class func setup() { FirebaseApp.configure() } + + func identify(id: String, username: String?, email: String?) { + Analytics.setUserID(id) + } + + func logEvent(_ event: Event, parameters: [String: Any]?) { + Analytics.logEvent(event.rawValue, parameters: parameters) + } + } diff --git a/OpenEdX/Managers/SegmentManager/SegmentManager.swift b/OpenEdX/Managers/SegmentManager/SegmentManager.swift index 70daa50c8..c134f4cb1 100644 --- a/OpenEdX/Managers/SegmentManager/SegmentManager.swift +++ b/OpenEdX/Managers/SegmentManager/SegmentManager.swift @@ -10,7 +10,7 @@ import Core import Segment import SegmentFirebase -class SegmentManager { +class SegmentManager: AnalyticsService { var analytics: Analytics? public func setup(with config: ConfigProtocol) { @@ -22,4 +22,20 @@ class SegmentManager { analytics?.add(plugin: FirebaseDestination()) } } + + func identify(id: String, username: String?, email: String?) { + guard let email = email, let username = username else { return } + let traits: [String: String] = [ + "email": email, + "username": username + ] + analytics?.identify(userId: id, traits: traits) + } + + func logEvent(_ event: Event, parameters: [String: Any]?) { + analytics?.track( + name: event.rawValue, + properties: parameters + ) + } } From 70285cca29c2cab314b8eb2a7804899d4194b05f Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Wed, 21 Feb 2024 14:36:12 +0100 Subject: [PATCH 022/136] chore: moved setup into managers init methods, renamed managers --- OpenEdX.xcodeproj/project.pbxproj | 16 ++++++++-------- OpenEdX/AppDelegate.swift | 13 +------------ OpenEdX/DI/AppAssembly.swift | 10 ++++++++-- .../AnalyticsManager/AnalyticsManager.swift | 14 ++++++++------ ...ager.swift => FirebaseAnalyticsManager.swift} | 6 ++++-- .../Providers/BrazeProvider.swift | 3 ++- ...nager.swift => SegmentAnalyticsManager.swift} | 5 +++-- 7 files changed, 34 insertions(+), 33 deletions(-) rename OpenEdX/Managers/FirebaseManager/{FirebaseManager.swift => FirebaseAnalyticsManager.swift} (81%) rename OpenEdX/Managers/SegmentManager/{SegmentManager.swift => SegmentAnalyticsManager.swift} (90%) diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 5f4a3c339..eee455ddb 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -57,9 +57,9 @@ A59568972B61653700ED4F90 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568962B61653700ED4F90 /* DeepLink.swift */; }; A59568992B616D9400ED4F90 /* PushLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568982B616D9400ED4F90 /* PushLink.swift */; }; A59585AF2B62A07100A35A20 /* BranchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59585AE2B62A07100A35A20 /* BranchService.swift */; }; - A59702292B83C87900CA064C /* FirebaseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59702282B83C87900CA064C /* FirebaseManager.swift */; }; + A59702292B83C87900CA064C /* FirebaseAnalyticsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59702282B83C87900CA064C /* FirebaseAnalyticsManager.swift */; }; A5BD3E302B83B0F7006A8983 /* SegmentFirebase in Frameworks */ = {isa = PBXBuildFile; productRef = A5BD3E2F2B83B0F7006A8983 /* SegmentFirebase */; }; - A5C10D8F2B861A70008E864D /* SegmentManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C10D8E2B861A70008E864D /* SegmentManager.swift */; }; + A5C10D8F2B861A70008E864D /* SegmentAnalyticsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C10D8E2B861A70008E864D /* SegmentAnalyticsManager.swift */; }; BA3042792B1F7147009B64B7 /* MSAL in Frameworks */ = {isa = PBXBuildFile; productRef = BA3042782B1F7147009B64B7 /* MSAL */; }; E0D6E6A32B1626B10089F9C9 /* Theme.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0D6E6A22B1626B10089F9C9 /* Theme.framework */; }; E0D6E6A42B1626D60089F9C9 /* Theme.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E0D6E6A22B1626B10089F9C9 /* Theme.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -139,8 +139,8 @@ A59568962B61653700ED4F90 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; A59568982B616D9400ED4F90 /* PushLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushLink.swift; sourceTree = ""; }; A59585AE2B62A07100A35A20 /* BranchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BranchService.swift; path = ../BranchService.swift; sourceTree = ""; }; - A59702282B83C87900CA064C /* FirebaseManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseManager.swift; sourceTree = ""; }; - A5C10D8E2B861A70008E864D /* SegmentManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentManager.swift; sourceTree = ""; }; + A59702282B83C87900CA064C /* FirebaseAnalyticsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseAnalyticsManager.swift; sourceTree = ""; }; + A5C10D8E2B861A70008E864D /* SegmentAnalyticsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentAnalyticsManager.swift; sourceTree = ""; }; A89AD827F52CF6A6B903606E /* Pods-App-OpenEdX.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasestage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasestage.xcconfig"; sourceTree = ""; }; BB08ACD2CCA33D6DDDDD31B4 /* Pods-App-OpenEdX.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasedev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasedev.xcconfig"; sourceTree = ""; }; E0D6E6A22B1626B10089F9C9 /* Theme.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Theme.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -348,7 +348,7 @@ A59702272B83C84800CA064C /* FirebaseManager */ = { isa = PBXGroup; children = ( - A59702282B83C87900CA064C /* FirebaseManager.swift */, + A59702282B83C87900CA064C /* FirebaseAnalyticsManager.swift */, ); path = FirebaseManager; sourceTree = ""; @@ -356,7 +356,7 @@ A5C10D8D2B861A56008E864D /* SegmentManager */ = { isa = PBXGroup; children = ( - A5C10D8E2B861A70008E864D /* SegmentManager.swift */, + A5C10D8E2B861A70008E864D /* SegmentAnalyticsManager.swift */, ); path = SegmentManager; sourceTree = ""; @@ -560,7 +560,7 @@ 0293A2032A6FCA590090A336 /* CorePersistence.swift in Sources */, 0770DE1E28D084E8006D8A5D /* AppAssembly.swift in Sources */, A50066932B614DCD0024680B /* FCMListener.swift in Sources */, - A59702292B83C87900CA064C /* FirebaseManager.swift in Sources */, + A59702292B83C87900CA064C /* FirebaseAnalyticsManager.swift in Sources */, A500668D2B6143000024680B /* FCMProvider.swift in Sources */, 025AD4AC2A6FB95C00AB8FA7 /* DatabaseManager.swift in Sources */, 024E69202AEFC3FB00FA0B59 /* MainScreenViewModel.swift in Sources */, @@ -571,7 +571,7 @@ A50066952B614DEF0024680B /* BrazeListener.swift in Sources */, 071009C928D1DB3F00344290 /* ScreenAssembly.swift in Sources */, A59568972B61653700ED4F90 /* DeepLink.swift in Sources */, - A5C10D8F2B861A70008E864D /* SegmentManager.swift in Sources */, + A5C10D8F2B861A70008E864D /* SegmentAnalyticsManager.swift in Sources */, A59568992B616D9400ED4F90 /* PushLink.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index 965d81b41..1f71d7209 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -34,9 +34,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { initDI() if let config = Container.shared.resolve(ConfigProtocol.self) { Theme.Shapes.isRoundedCorners = config.theme.isRoundedCorners - if config.firebase.isAnalyticsSourceFirebase { - FirebaseManager.setup() - } + if config.facebook.enabled { ApplicationDelegate.shared.application( application, @@ -44,9 +42,6 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ) } configureDeepLinkServices(launchOptions: launchOptions) - if config.segment.enabled { - configureSegment(config) - } } Theme.Fonts.registerFonts() @@ -169,10 +164,4 @@ class AppDelegate: UIResponder, UIApplicationDelegate { guard let deepLinkManager = Container.shared.resolve(DeepLinkManager.self) else { return } deepLinkManager.configureDeepLinkService(launchOptions: launchOptions) } - - // Segment - func configureSegment(_ config: ConfigProtocol) { - guard let segmentManager = Container.shared.resolve(SegmentManager.self) else { return } - segmentManager.setup(with: config) - } } diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 850bf8b17..91a0ddbb8 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -176,8 +176,14 @@ class AppAssembly: Assembly { ) }.inObjectScope(.container) - container.register(SegmentManager.self) { r in - SegmentManager() + container.register(SegmentAnalyticsManager.self) { r in + SegmentAnalyticsManager( + config: r.resolve(ConfigProtocol.self)! + ) + }.inObjectScope(.container) + + container.register(FirebaseAnalyticsManager.self) { r in + FirebaseAnalyticsManager() }.inObjectScope(.container) } } diff --git a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift index 0928c10ad..105605f64 100644 --- a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift +++ b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift @@ -37,13 +37,15 @@ class AnalyticsManager: AuthorizationAnalytics, private func servicesFor(config: ConfigProtocol) -> [AnalyticsService] { var analyticsServices: [AnalyticsService] = [] - // add Firebase Analytics Service if enabled - if config.firebase.isAnalyticsSourceFirebase { - analyticsServices.append(FirebaseManager()) + // add Firebase Analytics Service + if config.firebase.enabled && config.firebase.isAnalyticsSourceFirebase, + let firebaseManager = Container.shared.resolve(FirebaseAnalyticsManager.self) { + analyticsServices.append(firebaseManager) } - // add Segment Analytics Service if enabled - if config.firebase.isAnalyticsSourceSegment, - let segmentManager = Container.shared.resolve(SegmentManager.self) { + + // add Segment Analytics Service + if config.segment.enabled, + let segmentManager = Container.shared.resolve(SegmentAnalyticsManager.self) { analyticsServices.append(segmentManager) } return analyticsServices diff --git a/OpenEdX/Managers/FirebaseManager/FirebaseManager.swift b/OpenEdX/Managers/FirebaseManager/FirebaseAnalyticsManager.swift similarity index 81% rename from OpenEdX/Managers/FirebaseManager/FirebaseManager.swift rename to OpenEdX/Managers/FirebaseManager/FirebaseAnalyticsManager.swift index 2e44ef373..faf49a148 100644 --- a/OpenEdX/Managers/FirebaseManager/FirebaseManager.swift +++ b/OpenEdX/Managers/FirebaseManager/FirebaseAnalyticsManager.swift @@ -7,9 +7,11 @@ import Foundation import Firebase +import Core -class FirebaseManager: AnalyticsService { - class func setup() { +class FirebaseAnalyticsManager: AnalyticsService { + // Init manager + init() { FirebaseApp.configure() } diff --git a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift index dfb32a3e2..7b21b21be 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift @@ -12,7 +12,7 @@ import Swinject class BrazeProvider: PushNotificationsProvider { func didRegisterWithDeviceToken(deviceToken: Data) { - guard let segmentManager = Container.shared.resolve(SegmentManager.self) else { return } + guard let segmentManager = Container.shared.resolve(SegmentAnalyticsManager.self) else { return } segmentManager.analytics?.add( plugin: BrazeDestination( additionalConfiguration: { configuration in @@ -23,6 +23,7 @@ class BrazeProvider: PushNotificationsProvider { ) ) } + func didFailToRegisterForRemoteNotificationsWithError(error: Error) { } } diff --git a/OpenEdX/Managers/SegmentManager/SegmentManager.swift b/OpenEdX/Managers/SegmentManager/SegmentAnalyticsManager.swift similarity index 90% rename from OpenEdX/Managers/SegmentManager/SegmentManager.swift rename to OpenEdX/Managers/SegmentManager/SegmentAnalyticsManager.swift index c134f4cb1..960492a13 100644 --- a/OpenEdX/Managers/SegmentManager/SegmentManager.swift +++ b/OpenEdX/Managers/SegmentManager/SegmentAnalyticsManager.swift @@ -10,10 +10,11 @@ import Core import Segment import SegmentFirebase -class SegmentManager: AnalyticsService { +class SegmentAnalyticsManager: AnalyticsService { var analytics: Analytics? - public func setup(with config: ConfigProtocol) { + // Init manager + public init(config: ConfigProtocol) { let configuration = Configuration(writeKey: config.segment.writeKey) .trackApplicationLifecycleEvents(true) .flushInterval(10) From 17b8ee77524018353c1da9c29c4a0844d792ac95 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Wed, 21 Feb 2024 15:03:44 +0100 Subject: [PATCH 023/136] chore: added check if services are enabled while init --- OpenEdX/DI/AppAssembly.swift | 4 +++- .../Managers/FirebaseManager/FirebaseAnalyticsManager.swift | 4 +++- OpenEdX/Managers/SegmentManager/SegmentAnalyticsManager.swift | 2 ++ 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 91a0ddbb8..ac404b136 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -183,7 +183,9 @@ class AppAssembly: Assembly { }.inObjectScope(.container) container.register(FirebaseAnalyticsManager.self) { r in - FirebaseAnalyticsManager() + FirebaseAnalyticsManager( + config: r.resolve(ConfigProtocol.self)! + ) }.inObjectScope(.container) } } diff --git a/OpenEdX/Managers/FirebaseManager/FirebaseAnalyticsManager.swift b/OpenEdX/Managers/FirebaseManager/FirebaseAnalyticsManager.swift index faf49a148..7f7ac3bc2 100644 --- a/OpenEdX/Managers/FirebaseManager/FirebaseAnalyticsManager.swift +++ b/OpenEdX/Managers/FirebaseManager/FirebaseAnalyticsManager.swift @@ -11,7 +11,9 @@ import Core class FirebaseAnalyticsManager: AnalyticsService { // Init manager - init() { + public init(config: ConfigProtocol) { + guard config.firebase.enabled && config.firebase.isAnalyticsSourceFirebase else { return } + FirebaseApp.configure() } diff --git a/OpenEdX/Managers/SegmentManager/SegmentAnalyticsManager.swift b/OpenEdX/Managers/SegmentManager/SegmentAnalyticsManager.swift index 960492a13..aafc2f16b 100644 --- a/OpenEdX/Managers/SegmentManager/SegmentAnalyticsManager.swift +++ b/OpenEdX/Managers/SegmentManager/SegmentAnalyticsManager.swift @@ -15,6 +15,8 @@ class SegmentAnalyticsManager: AnalyticsService { // Init manager public init(config: ConfigProtocol) { + guard config.segment.enabled else { return } + let configuration = Configuration(writeKey: config.segment.writeKey) .trackApplicationLifecycleEvents(true) .flushInterval(10) From 1c4939b036e0a06b38c3f2c73d3847987cddc5a3 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Wed, 21 Feb 2024 15:15:29 +0100 Subject: [PATCH 024/136] chore: renamed managers group --- OpenEdX.xcodeproj/project.pbxproj | 12 ++++++------ .../FirebaseAnalyticsManager.swift | 2 +- .../SegmentAnalyticsManager.swift | 2 +- 3 files changed, 8 insertions(+), 8 deletions(-) rename OpenEdX/Managers/{FirebaseManager => FirebaseAnalyticsManager}/FirebaseAnalyticsManager.swift (94%) rename OpenEdX/Managers/{SegmentManager => SegmentAnalyticsManager}/SegmentAnalyticsManager.swift (97%) diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index eee455ddb..01a45e0a3 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -293,8 +293,8 @@ A59568932B6162E400ED4F90 /* DeepLinkManager */, A50066872B613E4B0024680B /* PushNotificationsManager */, A50066892B613E990024680B /* AnalyticsManager */, - A59702272B83C84800CA064C /* FirebaseManager */, - A5C10D8D2B861A56008E864D /* SegmentManager */, + A59702272B83C84800CA064C /* FirebaseAnalyticsManager */, + A5C10D8D2B861A56008E864D /* SegmentAnalyticsManager */, ); path = Managers; sourceTree = ""; @@ -345,20 +345,20 @@ path = Link; sourceTree = ""; }; - A59702272B83C84800CA064C /* FirebaseManager */ = { + A59702272B83C84800CA064C /* FirebaseAnalyticsManager */ = { isa = PBXGroup; children = ( A59702282B83C87900CA064C /* FirebaseAnalyticsManager.swift */, ); - path = FirebaseManager; + path = FirebaseAnalyticsManager; sourceTree = ""; }; - A5C10D8D2B861A56008E864D /* SegmentManager */ = { + A5C10D8D2B861A56008E864D /* SegmentAnalyticsManager */ = { isa = PBXGroup; children = ( A5C10D8E2B861A70008E864D /* SegmentAnalyticsManager.swift */, ); - path = SegmentManager; + path = SegmentAnalyticsManager; sourceTree = ""; }; A5F46FD02B692B140003EEEF /* Services */ = { diff --git a/OpenEdX/Managers/FirebaseManager/FirebaseAnalyticsManager.swift b/OpenEdX/Managers/FirebaseAnalyticsManager/FirebaseAnalyticsManager.swift similarity index 94% rename from OpenEdX/Managers/FirebaseManager/FirebaseAnalyticsManager.swift rename to OpenEdX/Managers/FirebaseAnalyticsManager/FirebaseAnalyticsManager.swift index 7f7ac3bc2..6c1ac0343 100644 --- a/OpenEdX/Managers/FirebaseManager/FirebaseAnalyticsManager.swift +++ b/OpenEdX/Managers/FirebaseAnalyticsManager/FirebaseAnalyticsManager.swift @@ -1,5 +1,5 @@ // -// FirebaseManager.swift +// FirebaseAnalyticsManager.swift // OpenEdX // // Created by Anton Yarmolenka on 19/02/2024. diff --git a/OpenEdX/Managers/SegmentManager/SegmentAnalyticsManager.swift b/OpenEdX/Managers/SegmentAnalyticsManager/SegmentAnalyticsManager.swift similarity index 97% rename from OpenEdX/Managers/SegmentManager/SegmentAnalyticsManager.swift rename to OpenEdX/Managers/SegmentAnalyticsManager/SegmentAnalyticsManager.swift index aafc2f16b..ba3012518 100644 --- a/OpenEdX/Managers/SegmentManager/SegmentAnalyticsManager.swift +++ b/OpenEdX/Managers/SegmentAnalyticsManager/SegmentAnalyticsManager.swift @@ -1,5 +1,5 @@ // -// SegmentManager.swift +// SegmentAnalyticsManager.swift // OpenEdX // // Created by Anton Yarmolenka on 21/02/2024. From 5eb6e20134caa3708648e309fbc3382eb67fc80d Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Wed, 21 Feb 2024 16:18:44 +0100 Subject: [PATCH 025/136] chore: cleanup code --- OpenEdX.xcodeproj/project.pbxproj | 8 ++--- .../DeepLinkManager/BranchService.swift | 35 ------------------- .../DeepLinkManager/DeepLinkManager.swift | 2 -- .../Providers/BrazeProvider.swift | 1 - 4 files changed, 4 insertions(+), 42 deletions(-) delete mode 100644 OpenEdX/Managers/DeepLinkManager/BranchService.swift diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 01a45e0a3..4a0109749 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -53,10 +53,10 @@ A51CDBE52B6D1E93009B6D4E /* Segment in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBE42B6D1E93009B6D4E /* Segment */; }; A51CDBED2B6D2BEE009B6D4E /* SegmentBraze in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBEC2B6D2BEE009B6D4E /* SegmentBraze */; }; A51CDBEF2B6D2BEE009B6D4E /* SegmentBrazeUI in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBEE2B6D2BEE009B6D4E /* SegmentBrazeUI */; }; + A5462D9C2B864AE0003B96A5 /* BranchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5462D9B2B864AE0003B96A5 /* BranchService.swift */; }; A59568952B61630500ED4F90 /* DeepLinkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568942B61630500ED4F90 /* DeepLinkManager.swift */; }; A59568972B61653700ED4F90 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568962B61653700ED4F90 /* DeepLink.swift */; }; A59568992B616D9400ED4F90 /* PushLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568982B616D9400ED4F90 /* PushLink.swift */; }; - A59585AF2B62A07100A35A20 /* BranchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59585AE2B62A07100A35A20 /* BranchService.swift */; }; A59702292B83C87900CA064C /* FirebaseAnalyticsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59702282B83C87900CA064C /* FirebaseAnalyticsManager.swift */; }; A5BD3E302B83B0F7006A8983 /* SegmentFirebase in Frameworks */ = {isa = PBXBuildFile; productRef = A5BD3E2F2B83B0F7006A8983 /* SegmentFirebase */; }; A5C10D8F2B861A70008E864D /* SegmentAnalyticsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C10D8E2B861A70008E864D /* SegmentAnalyticsManager.swift */; }; @@ -135,10 +135,10 @@ A50066902B61467B0024680B /* BrazeProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrazeProvider.swift; sourceTree = ""; }; A50066922B614DCD0024680B /* FCMListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FCMListener.swift; sourceTree = ""; }; A50066942B614DEF0024680B /* BrazeListener.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrazeListener.swift; sourceTree = ""; }; + A5462D9B2B864AE0003B96A5 /* BranchService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BranchService.swift; sourceTree = ""; }; A59568942B61630500ED4F90 /* DeepLinkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkManager.swift; sourceTree = ""; }; A59568962B61653700ED4F90 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; A59568982B616D9400ED4F90 /* PushLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushLink.swift; sourceTree = ""; }; - A59585AE2B62A07100A35A20 /* BranchService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = BranchService.swift; path = ../BranchService.swift; sourceTree = ""; }; A59702282B83C87900CA064C /* FirebaseAnalyticsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseAnalyticsManager.swift; sourceTree = ""; }; A5C10D8E2B861A70008E864D /* SegmentAnalyticsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentAnalyticsManager.swift; sourceTree = ""; }; A89AD827F52CF6A6B903606E /* Pods-App-OpenEdX.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasestage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasestage.xcconfig"; sourceTree = ""; }; @@ -364,7 +364,7 @@ A5F46FD02B692B140003EEEF /* Services */ = { isa = PBXGroup; children = ( - A59585AE2B62A07100A35A20 /* BranchService.swift */, + A5462D9B2B864AE0003B96A5 /* BranchService.swift */, ); path = Services; sourceTree = ""; @@ -550,7 +550,6 @@ 0293A2052A6FCD430090A336 /* CoursePersistence.swift in Sources */, 020CA5D92AA0A25300970AAF /* AppStorage.swift in Sources */, 0298DF302A4EF7230023A257 /* AnalyticsManager.swift in Sources */, - A59585AF2B62A07100A35A20 /* BranchService.swift in Sources */, A50066912B61467B0024680B /* BrazeProvider.swift in Sources */, 0293A2072A6FCDA30090A336 /* DiscoveryPersistence.swift in Sources */, 07D5DA3528D075AA00752FD9 /* AppDelegate.swift in Sources */, @@ -559,6 +558,7 @@ 0770DE5028D0A707006D8A5D /* NetworkAssembly.swift in Sources */, 0293A2032A6FCA590090A336 /* CorePersistence.swift in Sources */, 0770DE1E28D084E8006D8A5D /* AppAssembly.swift in Sources */, + A5462D9C2B864AE0003B96A5 /* BranchService.swift in Sources */, A50066932B614DCD0024680B /* FCMListener.swift in Sources */, A59702292B83C87900CA064C /* FirebaseAnalyticsManager.swift in Sources */, A500668D2B6143000024680B /* FCMProvider.swift in Sources */, diff --git a/OpenEdX/Managers/DeepLinkManager/BranchService.swift b/OpenEdX/Managers/DeepLinkManager/BranchService.swift deleted file mode 100644 index 4d36fb27d..000000000 --- a/OpenEdX/Managers/DeepLinkManager/BranchService.swift +++ /dev/null @@ -1,35 +0,0 @@ -// -// BranchService.swift -// OpenEdX -// -// Created by Anton Yarmolenka on 25/01/2024. -// - -import Foundation -import UIKit - -class BranchService: DeepLinkService { - // configure service - func configureWith(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { - - } - - // handle url - func handledURLWith( - app: UIApplication, - open url: URL, - options: [UIApplication.OpenURLOptionsKey: Any] - ) -> Bool { - false - } - - // This method process push notification with the link object - func processNotification(with link: PushLink) { - - } - - // This method process the deep link with response parameters - func processDeepLink(with params: [String: Any]) { - - } -} diff --git a/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift b/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift index 15be9c1d2..157bf2bb7 100644 --- a/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift +++ b/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift @@ -10,8 +10,6 @@ import Core import UIKit public protocol DeepLinkService { - func processNotification(with link: PushLink) - func processDeepLink(with params: [String: Any]) func configureWith(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) func handledURLWith(app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any]) -> Bool } diff --git a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift index 7b21b21be..acaddc0ba 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift @@ -6,7 +6,6 @@ // import Foundation -import UIKit import SegmentBrazeUI import Swinject From af25b64b97c47eb37ad3ac79fe26933dbd4c17ea Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Wed, 21 Feb 2024 17:07:54 +0100 Subject: [PATCH 026/136] chore: re-added Segment package through https source --- OpenEdX.xcodeproj/project.pbxproj | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 4a0109749..dbbde7246 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -50,10 +50,10 @@ A50066912B61467B0024680B /* BrazeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50066902B61467B0024680B /* BrazeProvider.swift */; }; A50066932B614DCD0024680B /* FCMListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50066922B614DCD0024680B /* FCMListener.swift */; }; A50066952B614DEF0024680B /* BrazeListener.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50066942B614DEF0024680B /* BrazeListener.swift */; }; - A51CDBE52B6D1E93009B6D4E /* Segment in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBE42B6D1E93009B6D4E /* Segment */; }; A51CDBED2B6D2BEE009B6D4E /* SegmentBraze in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBEC2B6D2BEE009B6D4E /* SegmentBraze */; }; A51CDBEF2B6D2BEE009B6D4E /* SegmentBrazeUI in Frameworks */ = {isa = PBXBuildFile; productRef = A51CDBEE2B6D2BEE009B6D4E /* SegmentBrazeUI */; }; A5462D9C2B864AE0003B96A5 /* BranchService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5462D9B2B864AE0003B96A5 /* BranchService.swift */; }; + A5462D9F2B865713003B96A5 /* Segment in Frameworks */ = {isa = PBXBuildFile; productRef = A5462D9E2B865713003B96A5 /* Segment */; }; A59568952B61630500ED4F90 /* DeepLinkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568942B61630500ED4F90 /* DeepLinkManager.swift */; }; A59568972B61653700ED4F90 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568962B61653700ED4F90 /* DeepLink.swift */; }; A59568992B616D9400ED4F90 /* PushLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568982B616D9400ED4F90 /* PushLink.swift */; }; @@ -158,7 +158,7 @@ 025DE1A428DB4DAE0053E0F4 /* Profile.framework in Frameworks */, 0770DE4B28D0A462006D8A5D /* Authorization.framework in Frameworks */, BA3042792B1F7147009B64B7 /* MSAL in Frameworks */, - A51CDBE52B6D1E93009B6D4E /* Segment in Frameworks */, + A5462D9F2B865713003B96A5 /* Segment in Frameworks */, 072787B128D34D83002E9142 /* Discovery.framework in Frameworks */, 0218196428F734FA00202564 /* Discussion.framework in Frameworks */, 0219C67728F4347600D64452 /* Course.framework in Frameworks */, @@ -392,10 +392,10 @@ name = OpenEdX; packageProductDependencies = ( BA3042782B1F7147009B64B7 /* MSAL */, - A51CDBE42B6D1E93009B6D4E /* Segment */, A51CDBEC2B6D2BEE009B6D4E /* SegmentBraze */, A51CDBEE2B6D2BEE009B6D4E /* SegmentBrazeUI */, A5BD3E2F2B83B0F7006A8983 /* SegmentFirebase */, + A5462D9E2B865713003B96A5 /* Segment */, ); productName = OpenEdX; productReference = 07D5DA3128D075AA00752FD9 /* OpenEdX.app */; @@ -428,9 +428,9 @@ mainGroup = 07D5DA2828D075AA00752FD9; packageReferences = ( BA3042772B1F7147009B64B7 /* XCRemoteSwiftPackageReference "microsoft-authentication-library-for-objc" */, - A51CDBE32B6D1E93009B6D4E /* XCRemoteSwiftPackageReference "analytics-swift" */, A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */, A5BD3E2E2B83B0F7006A8983 /* XCRemoteSwiftPackageReference "analytics-swift-firebase" */, + A5462D9D2B865713003B96A5 /* XCRemoteSwiftPackageReference "analytics-swift" */, ); productRefGroup = 07D5DA3228D075AA00752FD9 /* Products */; projectDirPath = ""; @@ -1195,20 +1195,20 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - A51CDBE32B6D1E93009B6D4E /* XCRemoteSwiftPackageReference "analytics-swift" */ = { + A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "git@github.com:segmentio/analytics-swift.git"; + repositoryURL = "https://github.com/braze-inc/braze-segment-swift"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.5.2; + minimumVersion = 2.2.0; }; }; - A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */ = { + A5462D9D2B865713003B96A5 /* XCRemoteSwiftPackageReference "analytics-swift" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/braze-inc/braze-segment-swift"; + repositoryURL = "https://github.com/segmentio/analytics-swift.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.2.0; + minimumVersion = 1.5.3; }; }; A5BD3E2E2B83B0F7006A8983 /* XCRemoteSwiftPackageReference "analytics-swift-firebase" */ = { @@ -1230,11 +1230,6 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - A51CDBE42B6D1E93009B6D4E /* Segment */ = { - isa = XCSwiftPackageProductDependency; - package = A51CDBE32B6D1E93009B6D4E /* XCRemoteSwiftPackageReference "analytics-swift" */; - productName = Segment; - }; A51CDBEC2B6D2BEE009B6D4E /* SegmentBraze */ = { isa = XCSwiftPackageProductDependency; package = A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */; @@ -1245,6 +1240,11 @@ package = A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */; productName = SegmentBrazeUI; }; + A5462D9E2B865713003B96A5 /* Segment */ = { + isa = XCSwiftPackageProductDependency; + package = A5462D9D2B865713003B96A5 /* XCRemoteSwiftPackageReference "analytics-swift" */; + productName = Segment; + }; A5BD3E2F2B83B0F7006A8983 /* SegmentFirebase */ = { isa = XCSwiftPackageProductDependency; package = A5BD3E2E2B83B0F7006A8983 /* XCRemoteSwiftPackageReference "analytics-swift-firebase" */; From 7c67115ad618948a8f40fd5564f466da2ba95398 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Wed, 21 Feb 2024 18:17:56 +0100 Subject: [PATCH 027/136] chore: generated mocks --- .../AuthorizationMock.generated.swift | 272 +++++++++++++++++- Course/CourseTests/CourseMock.generated.swift | 246 ++++++++++++++++ .../DashboardMock.generated.swift | 246 ++++++++++++++++ .../DiscoveryMock.generated.swift | 246 ++++++++++++++++ .../DiscussionMock.generated.swift | 246 ++++++++++++++++ .../ProfileTests/ProfileMock.generated.swift | 246 ++++++++++++++++ 6 files changed, 1490 insertions(+), 12 deletions(-) diff --git a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift index fbbb73a12..8a9b75959 100644 --- a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift +++ b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift @@ -509,10 +509,10 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { - open func setUserID(_ id: String) { - addInvocation(.m_setUserID__id(Parameter.value(`id`))) - let perform = methodPerformValue(.m_setUserID__id(Parameter.value(`id`))) as? (String) -> Void - perform?(`id`) + open func identify(id: String, username: String, email: String) { + addInvocation(.m_identify__id_idusername_usernameemail_email(Parameter.value(`id`), Parameter.value(`username`), Parameter.value(`email`))) + let perform = methodPerformValue(.m_identify__id_idusername_usernameemail_email(Parameter.value(`id`), Parameter.value(`username`), Parameter.value(`email`))) as? (String, String, String) -> Void + perform?(`id`, `username`, `email`) } open func userLogin(method: AuthMethod) { @@ -553,7 +553,7 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { fileprivate enum MethodType { - case m_setUserID__id(Parameter) + case m_identify__id_idusername_usernameemail_email(Parameter, Parameter, Parameter) case m_userLogin__method_method(Parameter) case m_signUpClicked case m_createAccountClicked @@ -563,9 +563,11 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { - case (.m_setUserID__id(let lhsId), .m_setUserID__id(let rhsId)): + case (.m_identify__id_idusername_usernameemail_email(let lhsId, let lhsUsername, let lhsEmail), .m_identify__id_idusername_usernameemail_email(let rhsId, let rhsUsername, let rhsEmail)): var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "_ id")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "id")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUsername, rhs: rhsUsername, with: matcher), lhsUsername, rhsUsername, "username")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEmail, rhs: rhsEmail, with: matcher), lhsEmail, rhsEmail, "email")) return Matcher.ComparisonResult(results) case (.m_userLogin__method_method(let lhsMethod), .m_userLogin__method_method(let rhsMethod)): @@ -591,7 +593,7 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { func intValue() -> Int { switch self { - case let .m_setUserID__id(p0): return p0.intValue + case let .m_identify__id_idusername_usernameemail_email(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case let .m_userLogin__method_method(p0): return p0.intValue case .m_signUpClicked: return 0 case .m_createAccountClicked: return 0 @@ -602,7 +604,7 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { } func assertionName() -> String { switch self { - case .m_setUserID__id: return ".setUserID(_:)" + case .m_identify__id_idusername_usernameemail_email: return ".identify(id:username:email:)" case .m_userLogin__method_method: return ".userLogin(method:)" case .m_signUpClicked: return ".signUpClicked()" case .m_createAccountClicked: return ".createAccountClicked()" @@ -627,7 +629,7 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { public struct Verify { fileprivate var method: MethodType - public static func setUserID(_ id: Parameter) -> Verify { return Verify(method: .m_setUserID__id(`id`))} + public static func identify(id: Parameter, username: Parameter, email: Parameter) -> Verify { return Verify(method: .m_identify__id_idusername_usernameemail_email(`id`, `username`, `email`))} public static func userLogin(method: Parameter) -> Verify { return Verify(method: .m_userLogin__method_method(`method`))} public static func signUpClicked() -> Verify { return Verify(method: .m_signUpClicked)} public static func createAccountClicked() -> Verify { return Verify(method: .m_createAccountClicked)} @@ -640,8 +642,8 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { fileprivate var method: MethodType var performs: Any - public static func setUserID(_ id: Parameter, perform: @escaping (String) -> Void) -> Perform { - return Perform(method: .m_setUserID__id(`id`), performs: perform) + public static func identify(id: Parameter, username: Parameter, email: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { + return Perform(method: .m_identify__id_idusername_usernameemail_email(`id`, `username`, `email`), performs: perform) } public static func userLogin(method: Parameter, perform: @escaping (AuthMethod) -> Void) -> Perform { return Perform(method: .m_userLogin__method_method(`method`), performs: perform) @@ -2436,3 +2438,249 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } +// MARK: - WebviewCookiesUpdateProtocol + +open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + public var authInteractor: AuthInteractorProtocol { + get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } + } + private var __p_authInteractor: (AuthInteractorProtocol)? + + public var cookiesReady: Bool { + get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } + set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } + } + private var __p_cookiesReady: (Bool)? + + public var updatingCookies: Bool { + get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } + set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } + } + private var __p_updatingCookies: (Bool)? + + public var errorMessage: String? { + get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } + set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } + } + private var __p_errorMessage: (String)? + + + + + + open func updateCookies(force: Bool, retryCount: Int) { + addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) + let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void + perform?(`force`, `retryCount`) + } + + + fileprivate enum MethodType { + case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) + case p_authInteractor_get + case p_cookiesReady_get + case p_cookiesReady_set(Parameter) + case p_updatingCookies_get + case p_updatingCookies_set(Parameter) + case p_errorMessage_get + case p_errorMessage_set(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) + return Matcher.ComparisonResult(results) + case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match + case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match + case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue + case .p_authInteractor_get: return 0 + case .p_cookiesReady_get: return 0 + case .p_cookiesReady_set(let newValue): return newValue.intValue + case .p_updatingCookies_get: return 0 + case .p_updatingCookies_set(let newValue): return newValue.intValue + case .p_errorMessage_get: return 0 + case .p_errorMessage_set(let newValue): return newValue.intValue + } + } + func assertionName() -> String { + switch self { + case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" + case .p_authInteractor_get: return "[get] .authInteractor" + case .p_cookiesReady_get: return "[get] .cookiesReady" + case .p_cookiesReady_set: return "[set] .cookiesReady" + case .p_updatingCookies_get: return "[get] .updatingCookies" + case .p_updatingCookies_set: return "[set] .updatingCookies" + case .p_errorMessage_get: return "[get] .errorMessage" + case .p_errorMessage_set: return "[set] .errorMessage" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { + return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} + public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } + public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } + public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } + public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } + public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } + public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } + public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { + return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index 3acd549eb..ba210fd9b 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -2628,3 +2628,249 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } +// MARK: - WebviewCookiesUpdateProtocol + +open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + public var authInteractor: AuthInteractorProtocol { + get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } + } + private var __p_authInteractor: (AuthInteractorProtocol)? + + public var cookiesReady: Bool { + get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } + set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } + } + private var __p_cookiesReady: (Bool)? + + public var updatingCookies: Bool { + get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } + set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } + } + private var __p_updatingCookies: (Bool)? + + public var errorMessage: String? { + get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } + set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } + } + private var __p_errorMessage: (String)? + + + + + + open func updateCookies(force: Bool, retryCount: Int) { + addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) + let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void + perform?(`force`, `retryCount`) + } + + + fileprivate enum MethodType { + case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) + case p_authInteractor_get + case p_cookiesReady_get + case p_cookiesReady_set(Parameter) + case p_updatingCookies_get + case p_updatingCookies_set(Parameter) + case p_errorMessage_get + case p_errorMessage_set(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) + return Matcher.ComparisonResult(results) + case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match + case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match + case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue + case .p_authInteractor_get: return 0 + case .p_cookiesReady_get: return 0 + case .p_cookiesReady_set(let newValue): return newValue.intValue + case .p_updatingCookies_get: return 0 + case .p_updatingCookies_set(let newValue): return newValue.intValue + case .p_errorMessage_get: return 0 + case .p_errorMessage_set(let newValue): return newValue.intValue + } + } + func assertionName() -> String { + switch self { + case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" + case .p_authInteractor_get: return "[get] .authInteractor" + case .p_cookiesReady_get: return "[get] .cookiesReady" + case .p_cookiesReady_set: return "[set] .cookiesReady" + case .p_updatingCookies_get: return "[get] .updatingCookies" + case .p_updatingCookies_set: return "[set] .updatingCookies" + case .p_errorMessage_get: return "[get] .errorMessage" + case .p_errorMessage_set: return "[set] .errorMessage" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { + return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} + public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } + public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } + public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } + public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } + public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } + public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } + public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { + return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index 2aa0f593d..221f07f4c 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -2106,3 +2106,249 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } +// MARK: - WebviewCookiesUpdateProtocol + +open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + public var authInteractor: AuthInteractorProtocol { + get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } + } + private var __p_authInteractor: (AuthInteractorProtocol)? + + public var cookiesReady: Bool { + get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } + set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } + } + private var __p_cookiesReady: (Bool)? + + public var updatingCookies: Bool { + get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } + set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } + } + private var __p_updatingCookies: (Bool)? + + public var errorMessage: String? { + get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } + set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } + } + private var __p_errorMessage: (String)? + + + + + + open func updateCookies(force: Bool, retryCount: Int) { + addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) + let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void + perform?(`force`, `retryCount`) + } + + + fileprivate enum MethodType { + case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) + case p_authInteractor_get + case p_cookiesReady_get + case p_cookiesReady_set(Parameter) + case p_updatingCookies_get + case p_updatingCookies_set(Parameter) + case p_errorMessage_get + case p_errorMessage_set(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) + return Matcher.ComparisonResult(results) + case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match + case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match + case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue + case .p_authInteractor_get: return 0 + case .p_cookiesReady_get: return 0 + case .p_cookiesReady_set(let newValue): return newValue.intValue + case .p_updatingCookies_get: return 0 + case .p_updatingCookies_set(let newValue): return newValue.intValue + case .p_errorMessage_get: return 0 + case .p_errorMessage_set(let newValue): return newValue.intValue + } + } + func assertionName() -> String { + switch self { + case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" + case .p_authInteractor_get: return "[get] .authInteractor" + case .p_cookiesReady_get: return "[get] .cookiesReady" + case .p_cookiesReady_set: return "[set] .cookiesReady" + case .p_updatingCookies_get: return "[get] .updatingCookies" + case .p_updatingCookies_set: return "[set] .updatingCookies" + case .p_errorMessage_get: return "[get] .errorMessage" + case .p_errorMessage_set: return "[set] .errorMessage" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { + return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} + public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } + public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } + public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } + public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } + public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } + public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } + public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { + return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + diff --git a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index 3522e8691..38bdf1d91 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -2363,3 +2363,249 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } +// MARK: - WebviewCookiesUpdateProtocol + +open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + public var authInteractor: AuthInteractorProtocol { + get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } + } + private var __p_authInteractor: (AuthInteractorProtocol)? + + public var cookiesReady: Bool { + get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } + set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } + } + private var __p_cookiesReady: (Bool)? + + public var updatingCookies: Bool { + get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } + set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } + } + private var __p_updatingCookies: (Bool)? + + public var errorMessage: String? { + get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } + set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } + } + private var __p_errorMessage: (String)? + + + + + + open func updateCookies(force: Bool, retryCount: Int) { + addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) + let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void + perform?(`force`, `retryCount`) + } + + + fileprivate enum MethodType { + case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) + case p_authInteractor_get + case p_cookiesReady_get + case p_cookiesReady_set(Parameter) + case p_updatingCookies_get + case p_updatingCookies_set(Parameter) + case p_errorMessage_get + case p_errorMessage_set(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) + return Matcher.ComparisonResult(results) + case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match + case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match + case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue + case .p_authInteractor_get: return 0 + case .p_cookiesReady_get: return 0 + case .p_cookiesReady_set(let newValue): return newValue.intValue + case .p_updatingCookies_get: return 0 + case .p_updatingCookies_set(let newValue): return newValue.intValue + case .p_errorMessage_get: return 0 + case .p_errorMessage_set(let newValue): return newValue.intValue + } + } + func assertionName() -> String { + switch self { + case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" + case .p_authInteractor_get: return "[get] .authInteractor" + case .p_cookiesReady_get: return "[get] .cookiesReady" + case .p_cookiesReady_set: return "[set] .cookiesReady" + case .p_updatingCookies_get: return "[get] .updatingCookies" + case .p_updatingCookies_set: return "[set] .updatingCookies" + case .p_errorMessage_get: return "[get] .errorMessage" + case .p_errorMessage_set: return "[set] .errorMessage" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { + return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} + public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } + public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } + public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } + public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } + public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } + public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } + public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { + return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index fad4812d4..101137b24 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -3184,3 +3184,249 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } +// MARK: - WebviewCookiesUpdateProtocol + +open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + public var authInteractor: AuthInteractorProtocol { + get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } + } + private var __p_authInteractor: (AuthInteractorProtocol)? + + public var cookiesReady: Bool { + get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } + set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } + } + private var __p_cookiesReady: (Bool)? + + public var updatingCookies: Bool { + get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } + set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } + } + private var __p_updatingCookies: (Bool)? + + public var errorMessage: String? { + get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } + set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } + } + private var __p_errorMessage: (String)? + + + + + + open func updateCookies(force: Bool, retryCount: Int) { + addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) + let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void + perform?(`force`, `retryCount`) + } + + + fileprivate enum MethodType { + case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) + case p_authInteractor_get + case p_cookiesReady_get + case p_cookiesReady_set(Parameter) + case p_updatingCookies_get + case p_updatingCookies_set(Parameter) + case p_errorMessage_get + case p_errorMessage_set(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) + return Matcher.ComparisonResult(results) + case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match + case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match + case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue + case .p_authInteractor_get: return 0 + case .p_cookiesReady_get: return 0 + case .p_cookiesReady_set(let newValue): return newValue.intValue + case .p_updatingCookies_get: return 0 + case .p_updatingCookies_set(let newValue): return newValue.intValue + case .p_errorMessage_get: return 0 + case .p_errorMessage_set(let newValue): return newValue.intValue + } + } + func assertionName() -> String { + switch self { + case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" + case .p_authInteractor_get: return "[get] .authInteractor" + case .p_cookiesReady_get: return "[get] .cookiesReady" + case .p_cookiesReady_set: return "[set] .cookiesReady" + case .p_updatingCookies_get: return "[get] .updatingCookies" + case .p_updatingCookies_set: return "[set] .updatingCookies" + case .p_errorMessage_get: return "[get] .errorMessage" + case .p_errorMessage_set: return "[set] .errorMessage" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { + return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} + public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } + public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } + public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } + public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } + public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } + public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } + public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { + return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 047a50d8d..75370f29d 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -3086,3 +3086,249 @@ open class ProfileRouterMock: ProfileRouter, Mock { } } +// MARK: - WebviewCookiesUpdateProtocol + +open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + public var authInteractor: AuthInteractorProtocol { + get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } + } + private var __p_authInteractor: (AuthInteractorProtocol)? + + public var cookiesReady: Bool { + get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } + set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } + } + private var __p_cookiesReady: (Bool)? + + public var updatingCookies: Bool { + get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } + set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } + } + private var __p_updatingCookies: (Bool)? + + public var errorMessage: String? { + get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } + set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } + } + private var __p_errorMessage: (String)? + + + + + + open func updateCookies(force: Bool, retryCount: Int) { + addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) + let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void + perform?(`force`, `retryCount`) + } + + + fileprivate enum MethodType { + case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) + case p_authInteractor_get + case p_cookiesReady_get + case p_cookiesReady_set(Parameter) + case p_updatingCookies_get + case p_updatingCookies_set(Parameter) + case p_errorMessage_get + case p_errorMessage_set(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) + return Matcher.ComparisonResult(results) + case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match + case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match + case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue + case .p_authInteractor_get: return 0 + case .p_cookiesReady_get: return 0 + case .p_cookiesReady_set(let newValue): return newValue.intValue + case .p_updatingCookies_get: return 0 + case .p_updatingCookies_set(let newValue): return newValue.intValue + case .p_errorMessage_get: return 0 + case .p_errorMessage_set(let newValue): return newValue.intValue + } + } + func assertionName() -> String { + switch self { + case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" + case .p_authInteractor_get: return "[get] .authInteractor" + case .p_cookiesReady_get: return "[get] .cookiesReady" + case .p_cookiesReady_set: return "[set] .cookiesReady" + case .p_updatingCookies_get: return "[get] .updatingCookies" + case .p_updatingCookies_set: return "[set] .updatingCookies" + case .p_errorMessage_get: return "[get] .errorMessage" + case .p_errorMessage_set: return "[set] .errorMessage" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { + return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} + public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } + public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } + public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } + public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } + public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } + public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } + public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { + return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + From f40b7f27bc2c75307e762bcf34f2ef5b3c10402c Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Thu, 22 Feb 2024 12:53:04 +0100 Subject: [PATCH 028/136] refactor: address review feedback, added test for segment config --- .../Configuration/Config/SegmentConfig.swift | 2 +- .../CoreTests/Configuration/ConfigTests.swift | 11 ++++++++ OpenEdX.xcodeproj/project.pbxproj | 28 +++++++++---------- OpenEdX/DI/AppAssembly.swift | 8 +++--- .../AnalyticsManager/AnalyticsManager.swift | 8 +++--- .../FirebaseAnalyticsService.swift} | 4 +-- .../Providers/BrazeProvider.swift | 4 +-- .../SegmentAnalyticsService.swift} | 6 ++-- 8 files changed, 41 insertions(+), 30 deletions(-) rename OpenEdX/Managers/{FirebaseAnalyticsManager/FirebaseAnalyticsManager.swift => FirebaseAnalyticsService/FirebaseAnalyticsService.swift} (86%) rename OpenEdX/Managers/{SegmentAnalyticsManager/SegmentAnalyticsManager.swift => SegmentAnalyticsService/SegmentAnalyticsService.swift} (87%) diff --git a/Core/Core/Configuration/Config/SegmentConfig.swift b/Core/Core/Configuration/Config/SegmentConfig.swift index 37ed53cc9..937a78015 100644 --- a/Core/Core/Configuration/Config/SegmentConfig.swift +++ b/Core/Core/Configuration/Config/SegmentConfig.swift @@ -18,8 +18,8 @@ public final class SegmentConfig: NSObject { init(dictionary: [String: AnyObject]) { super.init() - enabled = dictionary[SegmentKeys.enabled] as? Bool == true writeKey = dictionary[SegmentKeys.writeKey] as? String ?? "" + enabled = dictionary[SegmentKeys.enabled] as? Bool == true && !writeKey.isEmpty } } diff --git a/Core/CoreTests/Configuration/ConfigTests.swift b/Core/CoreTests/Configuration/ConfigTests.swift index 21ab17ddf..35a90c2b7 100644 --- a/Core/CoreTests/Configuration/ConfigTests.swift +++ b/Core/CoreTests/Configuration/ConfigTests.swift @@ -60,6 +60,10 @@ class ConfigTests: XCTestCase { "BRANCH": [ "ENABLED": true, "KEY": "testBranchKey" + ], + "SEGMENT_IO": [ + "ENABLED": true, + "SEGMENT_IO_WRITE_KEY": "testSegmentKey" ] ] @@ -136,4 +140,11 @@ class ConfigTests: XCTestCase { XCTAssertTrue(config.branch.enabled) XCTAssertEqual(config.branch.key, "testBranchKey") } + + func testSegmentConfigInitialization() { + let config = Config(properties: properties) + + XCTAssertTrue(config.segment.enabled) + XCTAssertEqual(config.segment.writeKey, "testSegmentKey") + } } diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index dbbde7246..a7084fabf 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -57,9 +57,9 @@ A59568952B61630500ED4F90 /* DeepLinkManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568942B61630500ED4F90 /* DeepLinkManager.swift */; }; A59568972B61653700ED4F90 /* DeepLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568962B61653700ED4F90 /* DeepLink.swift */; }; A59568992B616D9400ED4F90 /* PushLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59568982B616D9400ED4F90 /* PushLink.swift */; }; - A59702292B83C87900CA064C /* FirebaseAnalyticsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59702282B83C87900CA064C /* FirebaseAnalyticsManager.swift */; }; + A59702292B83C87900CA064C /* FirebaseAnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59702282B83C87900CA064C /* FirebaseAnalyticsService.swift */; }; A5BD3E302B83B0F7006A8983 /* SegmentFirebase in Frameworks */ = {isa = PBXBuildFile; productRef = A5BD3E2F2B83B0F7006A8983 /* SegmentFirebase */; }; - A5C10D8F2B861A70008E864D /* SegmentAnalyticsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C10D8E2B861A70008E864D /* SegmentAnalyticsManager.swift */; }; + A5C10D8F2B861A70008E864D /* SegmentAnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C10D8E2B861A70008E864D /* SegmentAnalyticsService.swift */; }; BA3042792B1F7147009B64B7 /* MSAL in Frameworks */ = {isa = PBXBuildFile; productRef = BA3042782B1F7147009B64B7 /* MSAL */; }; E0D6E6A32B1626B10089F9C9 /* Theme.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E0D6E6A22B1626B10089F9C9 /* Theme.framework */; }; E0D6E6A42B1626D60089F9C9 /* Theme.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E0D6E6A22B1626B10089F9C9 /* Theme.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; @@ -139,8 +139,8 @@ A59568942B61630500ED4F90 /* DeepLinkManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkManager.swift; sourceTree = ""; }; A59568962B61653700ED4F90 /* DeepLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLink.swift; sourceTree = ""; }; A59568982B616D9400ED4F90 /* PushLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushLink.swift; sourceTree = ""; }; - A59702282B83C87900CA064C /* FirebaseAnalyticsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseAnalyticsManager.swift; sourceTree = ""; }; - A5C10D8E2B861A70008E864D /* SegmentAnalyticsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentAnalyticsManager.swift; sourceTree = ""; }; + A59702282B83C87900CA064C /* FirebaseAnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseAnalyticsService.swift; sourceTree = ""; }; + A5C10D8E2B861A70008E864D /* SegmentAnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentAnalyticsService.swift; sourceTree = ""; }; A89AD827F52CF6A6B903606E /* Pods-App-OpenEdX.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasestage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasestage.xcconfig"; sourceTree = ""; }; BB08ACD2CCA33D6DDDDD31B4 /* Pods-App-OpenEdX.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasedev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasedev.xcconfig"; sourceTree = ""; }; E0D6E6A22B1626B10089F9C9 /* Theme.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Theme.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -293,8 +293,8 @@ A59568932B6162E400ED4F90 /* DeepLinkManager */, A50066872B613E4B0024680B /* PushNotificationsManager */, A50066892B613E990024680B /* AnalyticsManager */, - A59702272B83C84800CA064C /* FirebaseAnalyticsManager */, - A5C10D8D2B861A56008E864D /* SegmentAnalyticsManager */, + A59702272B83C84800CA064C /* FirebaseAnalyticsService */, + A5C10D8D2B861A56008E864D /* SegmentAnalyticsService */, ); path = Managers; sourceTree = ""; @@ -345,20 +345,20 @@ path = Link; sourceTree = ""; }; - A59702272B83C84800CA064C /* FirebaseAnalyticsManager */ = { + A59702272B83C84800CA064C /* FirebaseAnalyticsService */ = { isa = PBXGroup; children = ( - A59702282B83C87900CA064C /* FirebaseAnalyticsManager.swift */, + A59702282B83C87900CA064C /* FirebaseAnalyticsService.swift */, ); - path = FirebaseAnalyticsManager; + path = FirebaseAnalyticsService; sourceTree = ""; }; - A5C10D8D2B861A56008E864D /* SegmentAnalyticsManager */ = { + A5C10D8D2B861A56008E864D /* SegmentAnalyticsService */ = { isa = PBXGroup; children = ( - A5C10D8E2B861A70008E864D /* SegmentAnalyticsManager.swift */, + A5C10D8E2B861A70008E864D /* SegmentAnalyticsService.swift */, ); - path = SegmentAnalyticsManager; + path = SegmentAnalyticsService; sourceTree = ""; }; A5F46FD02B692B140003EEEF /* Services */ = { @@ -560,7 +560,7 @@ 0770DE1E28D084E8006D8A5D /* AppAssembly.swift in Sources */, A5462D9C2B864AE0003B96A5 /* BranchService.swift in Sources */, A50066932B614DCD0024680B /* FCMListener.swift in Sources */, - A59702292B83C87900CA064C /* FirebaseAnalyticsManager.swift in Sources */, + A59702292B83C87900CA064C /* FirebaseAnalyticsService.swift in Sources */, A500668D2B6143000024680B /* FCMProvider.swift in Sources */, 025AD4AC2A6FB95C00AB8FA7 /* DatabaseManager.swift in Sources */, 024E69202AEFC3FB00FA0B59 /* MainScreenViewModel.swift in Sources */, @@ -571,7 +571,7 @@ A50066952B614DEF0024680B /* BrazeListener.swift in Sources */, 071009C928D1DB3F00344290 /* ScreenAssembly.swift in Sources */, A59568972B61653700ED4F90 /* DeepLink.swift in Sources */, - A5C10D8F2B861A70008E864D /* SegmentAnalyticsManager.swift in Sources */, + A5C10D8F2B861A70008E864D /* SegmentAnalyticsService.swift in Sources */, A59568992B616D9400ED4F90 /* PushLink.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index ac404b136..2c65937c8 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -176,14 +176,14 @@ class AppAssembly: Assembly { ) }.inObjectScope(.container) - container.register(SegmentAnalyticsManager.self) { r in - SegmentAnalyticsManager( + container.register(SegmentAnalyticsService.self) { r in + SegmentAnalyticsService( config: r.resolve(ConfigProtocol.self)! ) }.inObjectScope(.container) - container.register(FirebaseAnalyticsManager.self) { r in - FirebaseAnalyticsManager( + container.register(FirebaseAnalyticsService.self) { r in + FirebaseAnalyticsService( config: r.resolve(ConfigProtocol.self)! ) }.inObjectScope(.container) diff --git a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift index 105605f64..4cb616fc8 100644 --- a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift +++ b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift @@ -39,14 +39,14 @@ class AnalyticsManager: AuthorizationAnalytics, var analyticsServices: [AnalyticsService] = [] // add Firebase Analytics Service if config.firebase.enabled && config.firebase.isAnalyticsSourceFirebase, - let firebaseManager = Container.shared.resolve(FirebaseAnalyticsManager.self) { - analyticsServices.append(firebaseManager) + let firebaseService = Container.shared.resolve(FirebaseAnalyticsService.self) { + analyticsServices.append(firebaseService) } // add Segment Analytics Service if config.segment.enabled, - let segmentManager = Container.shared.resolve(SegmentAnalyticsManager.self) { - analyticsServices.append(segmentManager) + let segmentService = Container.shared.resolve(SegmentAnalyticsService.self) { + analyticsServices.append(segmentService) } return analyticsServices } diff --git a/OpenEdX/Managers/FirebaseAnalyticsManager/FirebaseAnalyticsManager.swift b/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift similarity index 86% rename from OpenEdX/Managers/FirebaseAnalyticsManager/FirebaseAnalyticsManager.swift rename to OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift index 6c1ac0343..f41df2555 100644 --- a/OpenEdX/Managers/FirebaseAnalyticsManager/FirebaseAnalyticsManager.swift +++ b/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift @@ -1,5 +1,5 @@ // -// FirebaseAnalyticsManager.swift +// FirebaseAnalyticsService.swift // OpenEdX // // Created by Anton Yarmolenka on 19/02/2024. @@ -9,7 +9,7 @@ import Foundation import Firebase import Core -class FirebaseAnalyticsManager: AnalyticsService { +class FirebaseAnalyticsService: AnalyticsService { // Init manager public init(config: ConfigProtocol) { guard config.firebase.enabled && config.firebase.isAnalyticsSourceFirebase else { return } diff --git a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift index acaddc0ba..9e45059e3 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift @@ -11,8 +11,8 @@ import Swinject class BrazeProvider: PushNotificationsProvider { func didRegisterWithDeviceToken(deviceToken: Data) { - guard let segmentManager = Container.shared.resolve(SegmentAnalyticsManager.self) else { return } - segmentManager.analytics?.add( + guard let segmentService = Container.shared.resolve(SegmentAnalyticsService.self) else { return } + segmentService.analytics?.add( plugin: BrazeDestination( additionalConfiguration: { configuration in configuration.logger.level = .debug diff --git a/OpenEdX/Managers/SegmentAnalyticsManager/SegmentAnalyticsManager.swift b/OpenEdX/Managers/SegmentAnalyticsService/SegmentAnalyticsService.swift similarity index 87% rename from OpenEdX/Managers/SegmentAnalyticsManager/SegmentAnalyticsManager.swift rename to OpenEdX/Managers/SegmentAnalyticsService/SegmentAnalyticsService.swift index ba3012518..0d2315ec5 100644 --- a/OpenEdX/Managers/SegmentAnalyticsManager/SegmentAnalyticsManager.swift +++ b/OpenEdX/Managers/SegmentAnalyticsService/SegmentAnalyticsService.swift @@ -1,5 +1,5 @@ // -// SegmentAnalyticsManager.swift +// SegmentAnalyticsService.swift // OpenEdX // // Created by Anton Yarmolenka on 21/02/2024. @@ -10,7 +10,7 @@ import Core import Segment import SegmentFirebase -class SegmentAnalyticsManager: AnalyticsService { +class SegmentAnalyticsService: AnalyticsService { var analytics: Analytics? // Init manager @@ -21,7 +21,7 @@ class SegmentAnalyticsManager: AnalyticsService { .trackApplicationLifecycleEvents(true) .flushInterval(10) analytics = Analytics(configuration: configuration) - if config.firebase.isAnalyticsSourceSegment { + if config.firebase.enabled && config.firebase.isAnalyticsSourceSegment { analytics?.add(plugin: FirebaseDestination()) } } From 4f60316817a6722be82727d7b7d632679d868690 Mon Sep 17 00:00:00 2001 From: Gene <76485998+eyatsenkoperpetio@users.noreply.github.com> Date: Fri, 23 Feb 2024 11:46:47 +0100 Subject: [PATCH 029/136] feat: Don't delete downloaded videos when the user logs out (#289) * chore: add new logic with multi users * chore: change properties name * chore: cancel downloading if log out and title fixes * chore: resolve PR comments --- .../AuthorizationMock.generated.swift | 358 +++++++++++++++-- .../CoreDataModel.xcdatamodel/contents | 2 + .../Persistence/CorePersistenceProtocol.swift | 6 +- Core/Core/Network/DownloadManager.swift | 97 ++++- .../Container/CourseContainerView.swift | 19 +- .../Container/CourseContainerViewModel.swift | 2 +- Course/CourseTests/CourseMock.generated.swift | 358 +++++++++++++++-- .../CourseContainerViewModelTests.swift | 8 + .../DashboardMock.generated.swift | 358 +++++++++++++++-- .../DiscoveryMock.generated.swift | 358 +++++++++++++++-- .../DiscussionTopicsView.swift | 2 +- .../DiscussionTopicsViewModel.swift | 2 +- .../DiscussionMock.generated.swift | 358 +++++++++++++++-- OpenEdX/DI/ScreenAssembly.swift | 3 +- OpenEdX/Data/CorePersistence.swift | 350 ++++++++++------- OpenEdX/Router.swift | 12 +- Profile/Profile/Data/ProfileRepository.swift | 2 - .../Presentation/Profile/ProfileView.swift | 13 +- .../Profile/ProfileViewModel.swift | 4 + .../Profile/ProfileViewModelTests.swift | 18 +- .../ProfileTests/ProfileMock.generated.swift | 360 ++++++++++++++++-- 21 files changed, 2257 insertions(+), 433 deletions(-) diff --git a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift index fbbb73a12..9bf9f87dc 100644 --- a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift +++ b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift @@ -1931,6 +1931,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func addToDownloadQueue(blocks: [CourseBlock]) throws { + addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + do { + _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + open func getDownloadTasks() -> [DownloadDataTask] { addInvocation(.m_getDownloadTasks) let perform = methodPerformValue(.m_getDownloadTasks) as? () -> Void @@ -1998,6 +2011,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } + open func cancelAllDownloading() throws { + addInvocation(.m_cancelAllDownloading) + let perform = methodPerformValue(.m_cancelAllDownloading) as? () -> Void + perform?() + do { + _ = try methodReturnValue(.m_cancelAllDownloading).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + open func deleteFile(blocks: [CourseBlock]) { addInvocation(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) let perform = methodPerformValue(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void @@ -2023,12 +2049,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } - open func addToDownloadQueue(blocks: [CourseBlock]) throws { - addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) - let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void - perform?(`blocks`) + open func resumeDownloading() throws { + addInvocation(.m_resumeDownloading) + let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void + perform?() do { - _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + _ = try methodReturnValue(.m_resumeDownloading).casted() as Void } catch MockError.notStubed { // do nothing } catch { @@ -2050,34 +2076,22 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } - open func resumeDownloading() throws { - addInvocation(.m_resumeDownloading) - let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void - perform?() - do { - _ = try methodReturnValue(.m_resumeDownloading).casted() as Void - } catch MockError.notStubed { - // do nothing - } catch { - throw error - } - } - fileprivate enum MethodType { case m_publisher case m_eventPublisher + case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) case m_getDownloadTasks case m_getDownloadTasksForCourse__courseId(Parameter) case m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter, Parameter<[CourseBlock]>) case m_cancelDownloading__task_task(Parameter) case m_cancelDownloading__courseId_courseId(Parameter) + case m_cancelAllDownloading case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) - case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) - case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case m_resumeDownloading + case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -2086,6 +2100,11 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case (.m_eventPublisher, .m_eventPublisher): return .match + case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + case (.m_getDownloadTasks, .m_getDownloadTasks): return .match case (.m_getDownloadTasksForCourse__courseId(let lhsCourseid), .m_getDownloadTasksForCourse__courseId(let rhsCourseid)): @@ -2109,6 +2128,8 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) return Matcher.ComparisonResult(results) + case (.m_cancelAllDownloading, .m_cancelAllDownloading): return .match + case (.m_deleteFile__blocks_blocks(let lhsBlocks), .m_deleteFile__blocks_blocks(let rhsBlocks)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) @@ -2121,17 +2142,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) return Matcher.ComparisonResult(results) - case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) - return Matcher.ComparisonResult(results) + case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) return Matcher.ComparisonResult(results) - - case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } @@ -2141,17 +2157,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { switch self { case .m_publisher: return 0 case .m_eventPublisher: return 0 + case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue case .m_getDownloadTasks: return 0 case let .m_getDownloadTasksForCourse__courseId(p0): return p0.intValue case let .m_cancelDownloading__courseId_courseIdblocks_blocks(p0, p1): return p0.intValue + p1.intValue case let .m_cancelDownloading__task_task(p0): return p0.intValue case let .m_cancelDownloading__courseId_courseId(p0): return p0.intValue + case .m_cancelAllDownloading: return 0 case let .m_deleteFile__blocks_blocks(p0): return p0.intValue case .m_deleteAllFiles: return 0 case let .m_fileUrl__for_blockId(p0): return p0.intValue - case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue - case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .m_resumeDownloading: return 0 + case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .p_currentDownloadTask_get: return 0 } } @@ -2159,17 +2176,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { switch self { case .m_publisher: return ".publisher()" case .m_eventPublisher: return ".eventPublisher()" + case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" case .m_getDownloadTasks: return ".getDownloadTasks()" case .m_getDownloadTasksForCourse__courseId: return ".getDownloadTasksForCourse(_:)" case .m_cancelDownloading__courseId_courseIdblocks_blocks: return ".cancelDownloading(courseId:blocks:)" case .m_cancelDownloading__task_task: return ".cancelDownloading(task:)" case .m_cancelDownloading__courseId_courseId: return ".cancelDownloading(courseId:)" + case .m_cancelAllDownloading: return ".cancelAllDownloading()" case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" - case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" - case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .m_resumeDownloading: return ".resumeDownloading()" + case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } @@ -2247,6 +2265,16 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { return Given(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -2277,12 +2305,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { - return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + public static func cancelAllDownloading(willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelAllDownloading, products: willThrow.map({ StubProduct.throw($0) })) } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + public static func cancelAllDownloading(willProduce: (StubberThrows) -> Void) -> MethodStub { let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let given: Given = { return Given(method: .m_cancelAllDownloading, products: willThrow.map({ StubProduct.throw($0) })) }() let stubber = given.stubThrows(for: (Void).self) willProduce(stubber) return given @@ -2304,17 +2332,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func publisher() -> Verify { return Verify(method: .m_publisher)} public static func eventPublisher() -> Verify { return Verify(method: .m_eventPublisher)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} public static func getDownloadTasks() -> Verify { return Verify(method: .m_getDownloadTasks)} public static func getDownloadTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadTasksForCourse__courseId(`courseId`))} public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`))} public static func cancelDownloading(task: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__task_task(`task`))} public static func cancelDownloading(courseId: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseId(`courseId`))} + public static func cancelAllDownloading() -> Verify { return Verify(method: .m_cancelAllDownloading)} public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} public static func deleteAllFiles() -> Verify { return Verify(method: .m_deleteAllFiles)} public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} - public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } @@ -2328,6 +2357,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func eventPublisher(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_eventPublisher, performs: perform) } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + } public static func getDownloadTasks(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_getDownloadTasks, performs: perform) } @@ -2343,6 +2375,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func cancelDownloading(courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_cancelDownloading__courseId_courseId(`courseId`), performs: perform) } + public static func cancelAllDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_cancelAllDownloading, performs: perform) + } public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) } @@ -2352,14 +2387,257 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { - return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_resumeDownloading, performs: perform) } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), performs: perform) } - public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_resumeDownloading, performs: perform) + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + +// MARK: - WebviewCookiesUpdateProtocol + +open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + public var authInteractor: AuthInteractorProtocol { + get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } + } + private var __p_authInteractor: (AuthInteractorProtocol)? + + public var cookiesReady: Bool { + get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } + set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } + } + private var __p_cookiesReady: (Bool)? + + public var updatingCookies: Bool { + get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } + set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } + } + private var __p_updatingCookies: (Bool)? + + public var errorMessage: String? { + get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } + set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } + } + private var __p_errorMessage: (String)? + + + + + + open func updateCookies(force: Bool, retryCount: Int) { + addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) + let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void + perform?(`force`, `retryCount`) + } + + + fileprivate enum MethodType { + case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) + case p_authInteractor_get + case p_cookiesReady_get + case p_cookiesReady_set(Parameter) + case p_updatingCookies_get + case p_updatingCookies_set(Parameter) + case p_errorMessage_get + case p_errorMessage_set(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) + return Matcher.ComparisonResult(results) + case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match + case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match + case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue + case .p_authInteractor_get: return 0 + case .p_cookiesReady_get: return 0 + case .p_cookiesReady_set(let newValue): return newValue.intValue + case .p_updatingCookies_get: return 0 + case .p_updatingCookies_set(let newValue): return newValue.intValue + case .p_errorMessage_get: return 0 + case .p_errorMessage_set(let newValue): return newValue.intValue + } + } + func assertionName() -> String { + switch self { + case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" + case .p_authInteractor_get: return "[get] .authInteractor" + case .p_cookiesReady_get: return "[get] .cookiesReady" + case .p_cookiesReady_set: return "[set] .cookiesReady" + case .p_updatingCookies_get: return "[get] .updatingCookies" + case .p_updatingCookies_set: return "[set] .updatingCookies" + case .p_errorMessage_get: return "[get] .errorMessage" + case .p_errorMessage_set: return "[set] .errorMessage" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { + return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} + public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } + public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } + public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } + public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } + public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } + public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } + public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { + return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) } } diff --git a/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents b/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents index cd49e4370..2fd252f0d 100644 --- a/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -1,6 +1,7 @@ + @@ -11,6 +12,7 @@ + diff --git a/Core/Core/Data/Persistence/CorePersistenceProtocol.swift b/Core/Core/Data/Persistence/CorePersistenceProtocol.swift index 3a577ec50..3a6ec2acf 100644 --- a/Core/Core/Data/Persistence/CorePersistenceProtocol.swift +++ b/Core/Core/Data/Persistence/CorePersistenceProtocol.swift @@ -9,12 +9,14 @@ import CoreData import Combine public protocol CorePersistenceProtocol { + func set(userId: Int) + func getUserID() -> Int? func publisher() -> AnyPublisher func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) - func getNextBlockForDownloading() -> DownloadDataTask? + func nextBlockForDownloading() -> DownloadDataTask? func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) func deleteDownloadDataTask(id: String) throws - func saveDownloadDataTask(data: DownloadDataTask) + func saveDownloadDataTask(_ task: DownloadDataTask) func downloadDataTask(for blockId: String) -> DownloadDataTask? func downloadDataTask(for blockId: String, completion: @escaping (DownloadDataTask?) -> Void) func getDownloadDataTasks(completion: @escaping ([DownloadDataTask]) -> Void) diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index 4c2e6879f..2f967597a 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -33,6 +33,8 @@ public enum DownloadType: String { public struct DownloadDataTask: Identifiable, Hashable { public let id: String public let courseId: String + public let blockId: String + public let userId: Int public let url: String public let fileName: String public let displayName: String @@ -52,7 +54,9 @@ public struct DownloadDataTask: Identifiable, Hashable { public init( id: String, + blockId: String, courseId: String, + userId: Int, url: String, fileName: String, displayName: String, @@ -64,6 +68,8 @@ public struct DownloadDataTask: Identifiable, Hashable { ) { self.id = id self.courseId = courseId + self.blockId = blockId + self.userId = userId self.url = url self.fileName = fileName self.displayName = displayName @@ -73,6 +79,21 @@ public struct DownloadDataTask: Identifiable, Hashable { self.type = type self.fileSize = fileSize } + + public init(sourse: CDDownloadData) { + self.id = sourse.id ?? "" + self.blockId = sourse.blockId ?? "" + self.courseId = sourse.courseId ?? "" + self.userId = Int(sourse.userId) + self.url = sourse.url ?? "" + self.fileName = sourse.fileName ?? "" + self.displayName = sourse.displayName ?? "" + self.progress = sourse.progress + self.resumeData = sourse.resumeData + self.state = DownloadState(rawValue: sourse.state ?? "") ?? .waiting + self.type = DownloadType(rawValue: sourse.type ?? "") ?? .video + self.fileSize = Int(sourse.fileSize) + } } public class NoWiFiError: LocalizedError { @@ -85,19 +106,24 @@ public protocol DownloadManagerProtocol { func publisher() -> AnyPublisher func eventPublisher() -> AnyPublisher + func addToDownloadQueue(blocks: [CourseBlock]) throws + func getDownloadTasks() async -> [DownloadDataTask] func getDownloadTasksForCourse(_ courseId: String) async -> [DownloadDataTask] + func cancelDownloading(courseId: String, blocks: [CourseBlock]) async throws func cancelDownloading(task: DownloadDataTask) async throws func cancelDownloading(courseId: String) async throws + func cancelAllDownloading() async throws + func deleteFile(blocks: [CourseBlock]) async func deleteAllFiles() async + func fileUrl(for blockId: String) async -> URL? - func addToDownloadQueue(blocks: [CourseBlock]) throws - func isLargeVideosSize(blocks: [CourseBlock]) -> Bool func resumeDownloading() throws func fileUrl(for blockId: String) -> URL? + func isLargeVideosSize(blocks: [CourseBlock]) -> Bool } public enum DownloadManagerEvent { @@ -106,8 +132,9 @@ public enum DownloadManagerEvent { case progress(Double, DownloadDataTask) case paused(DownloadDataTask) case canceled(DownloadDataTask) - case finished(DownloadDataTask) case courseCanceled(String) + case allCanceled + case finished(DownloadDataTask) case deletedFile(String) case clearedAll } @@ -138,6 +165,9 @@ public class DownloadManager: DownloadManagerProtocol { connectivity: ConnectivityProtocol ) { self.persistence = persistence + if let userId = appStorage.user?.id { + self.persistence.set(userId: userId) + } self.appStorage = appStorage self.connectivity = connectivity self.backgroundTask() @@ -200,10 +230,10 @@ public class DownloadManager: DownloadManagerProtocol { public func cancelDownloading(courseId: String, blocks: [CourseBlock]) async throws { downloadRequest?.cancel() - let downloaded = await getDownloadTasksForCourse(courseId).filter { $0.state == .finished } - let blocksForDelete = blocks.filter { block in downloaded.first(where: { $0.id == block.id }) == nil } - + let blocksForDelete = blocks.filter { block in + downloaded.first(where: { $0.blockId == block.id }) == nil + } await deleteFile(blocks: blocksForDelete) downloaded.forEach { currentDownloadEventPublisher.send(.canceled($0)) @@ -226,22 +256,21 @@ public class DownloadManager: DownloadManagerProtocol { } public func cancelDownloading(courseId: String) async throws { - let downloads = await getDownloadTasksForCourse(courseId) - for downloadData in downloads { - do { - try persistence.deleteDownloadDataTask(id: downloadData.id) - if let fileUrl = await fileUrl(for: downloadData.id) { - try FileManager.default.removeItem(at: fileUrl) - } - } catch { - debugLog("Error deleting file: \(error.localizedDescription)") - } - } + let tasks = await getDownloadTasksForCourse(courseId) + await cancel(tasks: tasks) currentDownloadEventPublisher.send(.courseCanceled(courseId)) downloadRequest?.cancel() try newDownload() } + public func cancelAllDownloading() async throws { + let tasks = await getDownloadTasks().filter { $0.state != .finished } + await cancel(tasks: tasks) + currentDownloadEventPublisher.send(.allCanceled) + downloadRequest?.cancel() + try newDownload() + } + public func deleteFile(blocks: [CourseBlock]) async { for block in blocks { do { @@ -293,11 +322,13 @@ public class DownloadManager: DownloadManagerProtocol { return path?.appendingPathComponent(fileName) } + // MARK: - Private Intents + private func newDownload() throws { guard userCanDownload() else { throw NoWiFiError() } - guard let downloadTask = persistence.getNextBlockForDownloading() else { + guard let downloadTask = persistence.nextBlockForDownloading() else { isDownloadingInProgress = false return } @@ -378,7 +409,18 @@ public class DownloadManager: DownloadManagerProtocol { } } - // MARK: - Private Intents + private func cancel(tasks: [DownloadDataTask]) async { + for task in tasks { + do { + try persistence.deleteDownloadDataTask(id: task.id) + if let fileUrl = await fileUrl(for: task.id) { + try FileManager.default.removeItem(at: fileUrl) + } + } catch { + debugLog("Error deleting file: \(error.localizedDescription)") + } + } + } private func backgroundTask() { backgroundTaskProvider.eventPublisher() @@ -394,8 +436,8 @@ public class DownloadManager: DownloadManagerProtocol { lazy var videosFolderUrl: URL? = { let documentDirectoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] - let directoryURL = documentDirectoryURL.appendingPathComponent("Files", isDirectory: true) - + let directoryURL = documentDirectoryURL.appendingPathComponent(folderPathComponent, isDirectory: true) + if FileManager.default.fileExists(atPath: directoryURL.path) { return URL(fileURLWithPath: directoryURL.path) } else { @@ -413,6 +455,13 @@ public class DownloadManager: DownloadManagerProtocol { } }() + private var folderPathComponent: String { + if let id = appStorage.user?.id { + return "\(id)_Files" + } + return "Files" + } + private func saveFile(fileName: String, data: Data, folderURL: URL) { let fileURL = folderURL.appendingPathComponent(fileName) do { @@ -522,7 +571,9 @@ public class DownloadManagerMock: DownloadManagerProtocol { .canceled( .init( id: "", + blockId: "", courseId: "", + userId: 0, url: "", fileName: "", displayName: "", @@ -562,6 +613,10 @@ public class DownloadManagerMock: DownloadManagerProtocol { } + public func cancelAllDownloading() async throws { + + } + public func resumeDownloading() { } diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 79a797ddd..f2533d987 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -82,7 +82,7 @@ public struct CourseContainerView: View { } .navigationBarHidden(false) .navigationBarBackButtonHidden(false) - .navigationTitle(titleBar()) + .navigationTitle(title) .onChange(of: selection, perform: didSelect) .background(Theme.Colors.background) } @@ -211,23 +211,6 @@ public struct CourseContainerView: View { ) } } - - private func titleBar() -> String { - switch CourseTab(rawValue: selection) { - case .course: - return self.title - case .videos: - return self.title - case .dates: - return CourseLocalization.CourseContainer.dates - case .discussion: - return DiscussionLocalization.title - case .handounds: - return CourseLocalization.CourseContainer.handouts - default: - return "" - } - } } #if DEBUG diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index 9a7a34c4c..d4aa3913c 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -357,7 +357,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { for vertical in sequential.childs where vertical.isDownloadable { var verticalsChilds: [DownloadViewState] = [] for block in vertical.childs where block.isDownloadable { - if let download = courseDownloadTasks.first(where: { $0.id == block.id }) { + if let download = courseDownloadTasks.first(where: { $0.blockId == block.id }) { switch download.state { case .waiting, .inProgress: sequentialsChilds.append(.downloading) diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index 3acd549eb..dabfab196 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -2123,6 +2123,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func addToDownloadQueue(blocks: [CourseBlock]) throws { + addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + do { + _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + open func getDownloadTasks() -> [DownloadDataTask] { addInvocation(.m_getDownloadTasks) let perform = methodPerformValue(.m_getDownloadTasks) as? () -> Void @@ -2190,6 +2203,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } + open func cancelAllDownloading() throws { + addInvocation(.m_cancelAllDownloading) + let perform = methodPerformValue(.m_cancelAllDownloading) as? () -> Void + perform?() + do { + _ = try methodReturnValue(.m_cancelAllDownloading).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + open func deleteFile(blocks: [CourseBlock]) { addInvocation(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) let perform = methodPerformValue(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void @@ -2215,12 +2241,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } - open func addToDownloadQueue(blocks: [CourseBlock]) throws { - addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) - let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void - perform?(`blocks`) + open func resumeDownloading() throws { + addInvocation(.m_resumeDownloading) + let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void + perform?() do { - _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + _ = try methodReturnValue(.m_resumeDownloading).casted() as Void } catch MockError.notStubed { // do nothing } catch { @@ -2242,34 +2268,22 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } - open func resumeDownloading() throws { - addInvocation(.m_resumeDownloading) - let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void - perform?() - do { - _ = try methodReturnValue(.m_resumeDownloading).casted() as Void - } catch MockError.notStubed { - // do nothing - } catch { - throw error - } - } - fileprivate enum MethodType { case m_publisher case m_eventPublisher + case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) case m_getDownloadTasks case m_getDownloadTasksForCourse__courseId(Parameter) case m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter, Parameter<[CourseBlock]>) case m_cancelDownloading__task_task(Parameter) case m_cancelDownloading__courseId_courseId(Parameter) + case m_cancelAllDownloading case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) - case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) - case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case m_resumeDownloading + case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -2278,6 +2292,11 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case (.m_eventPublisher, .m_eventPublisher): return .match + case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + case (.m_getDownloadTasks, .m_getDownloadTasks): return .match case (.m_getDownloadTasksForCourse__courseId(let lhsCourseid), .m_getDownloadTasksForCourse__courseId(let rhsCourseid)): @@ -2301,6 +2320,8 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) return Matcher.ComparisonResult(results) + case (.m_cancelAllDownloading, .m_cancelAllDownloading): return .match + case (.m_deleteFile__blocks_blocks(let lhsBlocks), .m_deleteFile__blocks_blocks(let rhsBlocks)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) @@ -2313,17 +2334,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) return Matcher.ComparisonResult(results) - case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) - return Matcher.ComparisonResult(results) + case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) return Matcher.ComparisonResult(results) - - case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } @@ -2333,17 +2349,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { switch self { case .m_publisher: return 0 case .m_eventPublisher: return 0 + case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue case .m_getDownloadTasks: return 0 case let .m_getDownloadTasksForCourse__courseId(p0): return p0.intValue case let .m_cancelDownloading__courseId_courseIdblocks_blocks(p0, p1): return p0.intValue + p1.intValue case let .m_cancelDownloading__task_task(p0): return p0.intValue case let .m_cancelDownloading__courseId_courseId(p0): return p0.intValue + case .m_cancelAllDownloading: return 0 case let .m_deleteFile__blocks_blocks(p0): return p0.intValue case .m_deleteAllFiles: return 0 case let .m_fileUrl__for_blockId(p0): return p0.intValue - case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue - case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .m_resumeDownloading: return 0 + case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .p_currentDownloadTask_get: return 0 } } @@ -2351,17 +2368,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { switch self { case .m_publisher: return ".publisher()" case .m_eventPublisher: return ".eventPublisher()" + case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" case .m_getDownloadTasks: return ".getDownloadTasks()" case .m_getDownloadTasksForCourse__courseId: return ".getDownloadTasksForCourse(_:)" case .m_cancelDownloading__courseId_courseIdblocks_blocks: return ".cancelDownloading(courseId:blocks:)" case .m_cancelDownloading__task_task: return ".cancelDownloading(task:)" case .m_cancelDownloading__courseId_courseId: return ".cancelDownloading(courseId:)" + case .m_cancelAllDownloading: return ".cancelAllDownloading()" case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" - case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" - case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .m_resumeDownloading: return ".resumeDownloading()" + case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } @@ -2439,6 +2457,16 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { return Given(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -2469,12 +2497,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { - return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + public static func cancelAllDownloading(willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelAllDownloading, products: willThrow.map({ StubProduct.throw($0) })) } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + public static func cancelAllDownloading(willProduce: (StubberThrows) -> Void) -> MethodStub { let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let given: Given = { return Given(method: .m_cancelAllDownloading, products: willThrow.map({ StubProduct.throw($0) })) }() let stubber = given.stubThrows(for: (Void).self) willProduce(stubber) return given @@ -2496,17 +2524,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func publisher() -> Verify { return Verify(method: .m_publisher)} public static func eventPublisher() -> Verify { return Verify(method: .m_eventPublisher)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} public static func getDownloadTasks() -> Verify { return Verify(method: .m_getDownloadTasks)} public static func getDownloadTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadTasksForCourse__courseId(`courseId`))} public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`))} public static func cancelDownloading(task: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__task_task(`task`))} public static func cancelDownloading(courseId: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseId(`courseId`))} + public static func cancelAllDownloading() -> Verify { return Verify(method: .m_cancelAllDownloading)} public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} public static func deleteAllFiles() -> Verify { return Verify(method: .m_deleteAllFiles)} public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} - public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } @@ -2520,6 +2549,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func eventPublisher(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_eventPublisher, performs: perform) } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + } public static func getDownloadTasks(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_getDownloadTasks, performs: perform) } @@ -2535,6 +2567,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func cancelDownloading(courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_cancelDownloading__courseId_courseId(`courseId`), performs: perform) } + public static func cancelAllDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_cancelAllDownloading, performs: perform) + } public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) } @@ -2544,14 +2579,257 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { - return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_resumeDownloading, performs: perform) } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), performs: perform) } - public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_resumeDownloading, performs: perform) + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + +// MARK: - WebviewCookiesUpdateProtocol + +open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + public var authInteractor: AuthInteractorProtocol { + get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } + } + private var __p_authInteractor: (AuthInteractorProtocol)? + + public var cookiesReady: Bool { + get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } + set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } + } + private var __p_cookiesReady: (Bool)? + + public var updatingCookies: Bool { + get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } + set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } + } + private var __p_updatingCookies: (Bool)? + + public var errorMessage: String? { + get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } + set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } + } + private var __p_errorMessage: (String)? + + + + + + open func updateCookies(force: Bool, retryCount: Int) { + addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) + let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void + perform?(`force`, `retryCount`) + } + + + fileprivate enum MethodType { + case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) + case p_authInteractor_get + case p_cookiesReady_get + case p_cookiesReady_set(Parameter) + case p_updatingCookies_get + case p_updatingCookies_set(Parameter) + case p_errorMessage_get + case p_errorMessage_set(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) + return Matcher.ComparisonResult(results) + case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match + case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match + case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue + case .p_authInteractor_get: return 0 + case .p_cookiesReady_get: return 0 + case .p_cookiesReady_set(let newValue): return newValue.intValue + case .p_updatingCookies_get: return 0 + case .p_updatingCookies_set(let newValue): return newValue.intValue + case .p_errorMessage_get: return 0 + case .p_errorMessage_set(let newValue): return newValue.intValue + } + } + func assertionName() -> String { + switch self { + case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" + case .p_authInteractor_get: return "[get] .authInteractor" + case .p_cookiesReady_get: return "[get] .cookiesReady" + case .p_cookiesReady_set: return "[set] .cookiesReady" + case .p_updatingCookies_get: return "[get] .updatingCookies" + case .p_updatingCookies_set: return "[set] .updatingCookies" + case .p_errorMessage_get: return "[get] .errorMessage" + case .p_errorMessage_set: return "[set] .errorMessage" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { + return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} + public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } + public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } + public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } + public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } + public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } + public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } + public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { + return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) } } diff --git a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift index ddbcce7bf..57948a995 100644 --- a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift @@ -414,7 +414,9 @@ final class CourseContainerViewModelTests: XCTestCase { let downloadData = DownloadDataTask( id: "1", + blockId: "1", courseId: "course123", + userId: 1, url: "https://example.com/file.mp4", fileName: "file.mp4", displayName: "file.mp4", @@ -890,7 +892,9 @@ final class CourseContainerViewModelTests: XCTestCase { let downloadData = DownloadDataTask( id: "1", + blockId: "1", courseId: "course123", + userId: 1, url: "https://example.com/file.mp4", fileName: "file.mp4", displayName: "file.mp4", @@ -1014,7 +1018,9 @@ final class CourseContainerViewModelTests: XCTestCase { let downloadData = DownloadDataTask( id: "1", + blockId: "1", courseId: "course123", + userId: 1, url: "https://example.com/file.mp4", fileName: "file.mp4", displayName: "file.mp4", @@ -1157,7 +1163,9 @@ final class CourseContainerViewModelTests: XCTestCase { let downloadData = DownloadDataTask( id: "1", + blockId: "1", courseId: "course123", + userId: 1, url: "https://example.com/file.mp4", fileName: "file.mp4", displayName: "file.mp4", diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index 2aa0f593d..b8a417000 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -1601,6 +1601,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func addToDownloadQueue(blocks: [CourseBlock]) throws { + addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + do { + _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + open func getDownloadTasks() -> [DownloadDataTask] { addInvocation(.m_getDownloadTasks) let perform = methodPerformValue(.m_getDownloadTasks) as? () -> Void @@ -1668,6 +1681,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } + open func cancelAllDownloading() throws { + addInvocation(.m_cancelAllDownloading) + let perform = methodPerformValue(.m_cancelAllDownloading) as? () -> Void + perform?() + do { + _ = try methodReturnValue(.m_cancelAllDownloading).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + open func deleteFile(blocks: [CourseBlock]) { addInvocation(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) let perform = methodPerformValue(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void @@ -1693,12 +1719,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } - open func addToDownloadQueue(blocks: [CourseBlock]) throws { - addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) - let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void - perform?(`blocks`) + open func resumeDownloading() throws { + addInvocation(.m_resumeDownloading) + let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void + perform?() do { - _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + _ = try methodReturnValue(.m_resumeDownloading).casted() as Void } catch MockError.notStubed { // do nothing } catch { @@ -1720,34 +1746,22 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } - open func resumeDownloading() throws { - addInvocation(.m_resumeDownloading) - let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void - perform?() - do { - _ = try methodReturnValue(.m_resumeDownloading).casted() as Void - } catch MockError.notStubed { - // do nothing - } catch { - throw error - } - } - fileprivate enum MethodType { case m_publisher case m_eventPublisher + case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) case m_getDownloadTasks case m_getDownloadTasksForCourse__courseId(Parameter) case m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter, Parameter<[CourseBlock]>) case m_cancelDownloading__task_task(Parameter) case m_cancelDownloading__courseId_courseId(Parameter) + case m_cancelAllDownloading case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) - case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) - case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case m_resumeDownloading + case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -1756,6 +1770,11 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case (.m_eventPublisher, .m_eventPublisher): return .match + case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + case (.m_getDownloadTasks, .m_getDownloadTasks): return .match case (.m_getDownloadTasksForCourse__courseId(let lhsCourseid), .m_getDownloadTasksForCourse__courseId(let rhsCourseid)): @@ -1779,6 +1798,8 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) return Matcher.ComparisonResult(results) + case (.m_cancelAllDownloading, .m_cancelAllDownloading): return .match + case (.m_deleteFile__blocks_blocks(let lhsBlocks), .m_deleteFile__blocks_blocks(let rhsBlocks)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) @@ -1791,17 +1812,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) return Matcher.ComparisonResult(results) - case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) - return Matcher.ComparisonResult(results) + case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) return Matcher.ComparisonResult(results) - - case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } @@ -1811,17 +1827,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { switch self { case .m_publisher: return 0 case .m_eventPublisher: return 0 + case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue case .m_getDownloadTasks: return 0 case let .m_getDownloadTasksForCourse__courseId(p0): return p0.intValue case let .m_cancelDownloading__courseId_courseIdblocks_blocks(p0, p1): return p0.intValue + p1.intValue case let .m_cancelDownloading__task_task(p0): return p0.intValue case let .m_cancelDownloading__courseId_courseId(p0): return p0.intValue + case .m_cancelAllDownloading: return 0 case let .m_deleteFile__blocks_blocks(p0): return p0.intValue case .m_deleteAllFiles: return 0 case let .m_fileUrl__for_blockId(p0): return p0.intValue - case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue - case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .m_resumeDownloading: return 0 + case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .p_currentDownloadTask_get: return 0 } } @@ -1829,17 +1846,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { switch self { case .m_publisher: return ".publisher()" case .m_eventPublisher: return ".eventPublisher()" + case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" case .m_getDownloadTasks: return ".getDownloadTasks()" case .m_getDownloadTasksForCourse__courseId: return ".getDownloadTasksForCourse(_:)" case .m_cancelDownloading__courseId_courseIdblocks_blocks: return ".cancelDownloading(courseId:blocks:)" case .m_cancelDownloading__task_task: return ".cancelDownloading(task:)" case .m_cancelDownloading__courseId_courseId: return ".cancelDownloading(courseId:)" + case .m_cancelAllDownloading: return ".cancelAllDownloading()" case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" - case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" - case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .m_resumeDownloading: return ".resumeDownloading()" + case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } @@ -1917,6 +1935,16 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { return Given(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -1947,12 +1975,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { - return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + public static func cancelAllDownloading(willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelAllDownloading, products: willThrow.map({ StubProduct.throw($0) })) } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + public static func cancelAllDownloading(willProduce: (StubberThrows) -> Void) -> MethodStub { let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let given: Given = { return Given(method: .m_cancelAllDownloading, products: willThrow.map({ StubProduct.throw($0) })) }() let stubber = given.stubThrows(for: (Void).self) willProduce(stubber) return given @@ -1974,17 +2002,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func publisher() -> Verify { return Verify(method: .m_publisher)} public static func eventPublisher() -> Verify { return Verify(method: .m_eventPublisher)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} public static func getDownloadTasks() -> Verify { return Verify(method: .m_getDownloadTasks)} public static func getDownloadTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadTasksForCourse__courseId(`courseId`))} public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`))} public static func cancelDownloading(task: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__task_task(`task`))} public static func cancelDownloading(courseId: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseId(`courseId`))} + public static func cancelAllDownloading() -> Verify { return Verify(method: .m_cancelAllDownloading)} public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} public static func deleteAllFiles() -> Verify { return Verify(method: .m_deleteAllFiles)} public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} - public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } @@ -1998,6 +2027,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func eventPublisher(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_eventPublisher, performs: perform) } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + } public static func getDownloadTasks(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_getDownloadTasks, performs: perform) } @@ -2013,6 +2045,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func cancelDownloading(courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_cancelDownloading__courseId_courseId(`courseId`), performs: perform) } + public static func cancelAllDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_cancelAllDownloading, performs: perform) + } public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) } @@ -2022,14 +2057,257 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { - return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_resumeDownloading, performs: perform) } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), performs: perform) } - public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_resumeDownloading, performs: perform) + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + +// MARK: - WebviewCookiesUpdateProtocol + +open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + public var authInteractor: AuthInteractorProtocol { + get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } + } + private var __p_authInteractor: (AuthInteractorProtocol)? + + public var cookiesReady: Bool { + get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } + set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } + } + private var __p_cookiesReady: (Bool)? + + public var updatingCookies: Bool { + get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } + set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } + } + private var __p_updatingCookies: (Bool)? + + public var errorMessage: String? { + get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } + set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } + } + private var __p_errorMessage: (String)? + + + + + + open func updateCookies(force: Bool, retryCount: Int) { + addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) + let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void + perform?(`force`, `retryCount`) + } + + + fileprivate enum MethodType { + case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) + case p_authInteractor_get + case p_cookiesReady_get + case p_cookiesReady_set(Parameter) + case p_updatingCookies_get + case p_updatingCookies_set(Parameter) + case p_errorMessage_get + case p_errorMessage_set(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) + return Matcher.ComparisonResult(results) + case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match + case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match + case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue + case .p_authInteractor_get: return 0 + case .p_cookiesReady_get: return 0 + case .p_cookiesReady_set(let newValue): return newValue.intValue + case .p_updatingCookies_get: return 0 + case .p_updatingCookies_set(let newValue): return newValue.intValue + case .p_errorMessage_get: return 0 + case .p_errorMessage_set(let newValue): return newValue.intValue + } + } + func assertionName() -> String { + switch self { + case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" + case .p_authInteractor_get: return "[get] .authInteractor" + case .p_cookiesReady_get: return "[get] .cookiesReady" + case .p_cookiesReady_set: return "[set] .cookiesReady" + case .p_updatingCookies_get: return "[get] .updatingCookies" + case .p_updatingCookies_set: return "[set] .updatingCookies" + case .p_errorMessage_get: return "[get] .errorMessage" + case .p_errorMessage_set: return "[set] .errorMessage" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { + return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} + public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } + public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } + public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } + public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } + public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } + public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } + public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { + return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) } } diff --git a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index 3522e8691..ef22e064f 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -1858,6 +1858,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func addToDownloadQueue(blocks: [CourseBlock]) throws { + addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + do { + _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + open func getDownloadTasks() -> [DownloadDataTask] { addInvocation(.m_getDownloadTasks) let perform = methodPerformValue(.m_getDownloadTasks) as? () -> Void @@ -1925,6 +1938,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } + open func cancelAllDownloading() throws { + addInvocation(.m_cancelAllDownloading) + let perform = methodPerformValue(.m_cancelAllDownloading) as? () -> Void + perform?() + do { + _ = try methodReturnValue(.m_cancelAllDownloading).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + open func deleteFile(blocks: [CourseBlock]) { addInvocation(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) let perform = methodPerformValue(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void @@ -1950,12 +1976,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } - open func addToDownloadQueue(blocks: [CourseBlock]) throws { - addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) - let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void - perform?(`blocks`) + open func resumeDownloading() throws { + addInvocation(.m_resumeDownloading) + let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void + perform?() do { - _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + _ = try methodReturnValue(.m_resumeDownloading).casted() as Void } catch MockError.notStubed { // do nothing } catch { @@ -1977,34 +2003,22 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } - open func resumeDownloading() throws { - addInvocation(.m_resumeDownloading) - let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void - perform?() - do { - _ = try methodReturnValue(.m_resumeDownloading).casted() as Void - } catch MockError.notStubed { - // do nothing - } catch { - throw error - } - } - fileprivate enum MethodType { case m_publisher case m_eventPublisher + case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) case m_getDownloadTasks case m_getDownloadTasksForCourse__courseId(Parameter) case m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter, Parameter<[CourseBlock]>) case m_cancelDownloading__task_task(Parameter) case m_cancelDownloading__courseId_courseId(Parameter) + case m_cancelAllDownloading case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) - case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) - case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case m_resumeDownloading + case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -2013,6 +2027,11 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case (.m_eventPublisher, .m_eventPublisher): return .match + case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + case (.m_getDownloadTasks, .m_getDownloadTasks): return .match case (.m_getDownloadTasksForCourse__courseId(let lhsCourseid), .m_getDownloadTasksForCourse__courseId(let rhsCourseid)): @@ -2036,6 +2055,8 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) return Matcher.ComparisonResult(results) + case (.m_cancelAllDownloading, .m_cancelAllDownloading): return .match + case (.m_deleteFile__blocks_blocks(let lhsBlocks), .m_deleteFile__blocks_blocks(let rhsBlocks)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) @@ -2048,17 +2069,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) return Matcher.ComparisonResult(results) - case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) - return Matcher.ComparisonResult(results) + case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) return Matcher.ComparisonResult(results) - - case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } @@ -2068,17 +2084,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { switch self { case .m_publisher: return 0 case .m_eventPublisher: return 0 + case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue case .m_getDownloadTasks: return 0 case let .m_getDownloadTasksForCourse__courseId(p0): return p0.intValue case let .m_cancelDownloading__courseId_courseIdblocks_blocks(p0, p1): return p0.intValue + p1.intValue case let .m_cancelDownloading__task_task(p0): return p0.intValue case let .m_cancelDownloading__courseId_courseId(p0): return p0.intValue + case .m_cancelAllDownloading: return 0 case let .m_deleteFile__blocks_blocks(p0): return p0.intValue case .m_deleteAllFiles: return 0 case let .m_fileUrl__for_blockId(p0): return p0.intValue - case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue - case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .m_resumeDownloading: return 0 + case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .p_currentDownloadTask_get: return 0 } } @@ -2086,17 +2103,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { switch self { case .m_publisher: return ".publisher()" case .m_eventPublisher: return ".eventPublisher()" + case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" case .m_getDownloadTasks: return ".getDownloadTasks()" case .m_getDownloadTasksForCourse__courseId: return ".getDownloadTasksForCourse(_:)" case .m_cancelDownloading__courseId_courseIdblocks_blocks: return ".cancelDownloading(courseId:blocks:)" case .m_cancelDownloading__task_task: return ".cancelDownloading(task:)" case .m_cancelDownloading__courseId_courseId: return ".cancelDownloading(courseId:)" + case .m_cancelAllDownloading: return ".cancelAllDownloading()" case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" - case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" - case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .m_resumeDownloading: return ".resumeDownloading()" + case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } @@ -2174,6 +2192,16 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { return Given(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -2204,12 +2232,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { - return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + public static func cancelAllDownloading(willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelAllDownloading, products: willThrow.map({ StubProduct.throw($0) })) } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + public static func cancelAllDownloading(willProduce: (StubberThrows) -> Void) -> MethodStub { let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let given: Given = { return Given(method: .m_cancelAllDownloading, products: willThrow.map({ StubProduct.throw($0) })) }() let stubber = given.stubThrows(for: (Void).self) willProduce(stubber) return given @@ -2231,17 +2259,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func publisher() -> Verify { return Verify(method: .m_publisher)} public static func eventPublisher() -> Verify { return Verify(method: .m_eventPublisher)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} public static func getDownloadTasks() -> Verify { return Verify(method: .m_getDownloadTasks)} public static func getDownloadTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadTasksForCourse__courseId(`courseId`))} public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`))} public static func cancelDownloading(task: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__task_task(`task`))} public static func cancelDownloading(courseId: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseId(`courseId`))} + public static func cancelAllDownloading() -> Verify { return Verify(method: .m_cancelAllDownloading)} public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} public static func deleteAllFiles() -> Verify { return Verify(method: .m_deleteAllFiles)} public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} - public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } @@ -2255,6 +2284,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func eventPublisher(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_eventPublisher, performs: perform) } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + } public static func getDownloadTasks(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_getDownloadTasks, performs: perform) } @@ -2270,6 +2302,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func cancelDownloading(courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_cancelDownloading__courseId_courseId(`courseId`), performs: perform) } + public static func cancelAllDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_cancelAllDownloading, performs: perform) + } public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) } @@ -2279,14 +2314,257 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { - return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_resumeDownloading, performs: perform) } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), performs: perform) } - public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_resumeDownloading, performs: perform) + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + +// MARK: - WebviewCookiesUpdateProtocol + +open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + public var authInteractor: AuthInteractorProtocol { + get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } + } + private var __p_authInteractor: (AuthInteractorProtocol)? + + public var cookiesReady: Bool { + get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } + set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } + } + private var __p_cookiesReady: (Bool)? + + public var updatingCookies: Bool { + get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } + set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } + } + private var __p_updatingCookies: (Bool)? + + public var errorMessage: String? { + get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } + set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } + } + private var __p_errorMessage: (String)? + + + + + + open func updateCookies(force: Bool, retryCount: Int) { + addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) + let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void + perform?(`force`, `retryCount`) + } + + + fileprivate enum MethodType { + case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) + case p_authInteractor_get + case p_cookiesReady_get + case p_cookiesReady_set(Parameter) + case p_updatingCookies_get + case p_updatingCookies_set(Parameter) + case p_errorMessage_get + case p_errorMessage_set(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) + return Matcher.ComparisonResult(results) + case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match + case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match + case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue + case .p_authInteractor_get: return 0 + case .p_cookiesReady_get: return 0 + case .p_cookiesReady_set(let newValue): return newValue.intValue + case .p_updatingCookies_get: return 0 + case .p_updatingCookies_set(let newValue): return newValue.intValue + case .p_errorMessage_get: return 0 + case .p_errorMessage_set(let newValue): return newValue.intValue + } + } + func assertionName() -> String { + switch self { + case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" + case .p_authInteractor_get: return "[get] .authInteractor" + case .p_cookiesReady_get: return "[get] .cookiesReady" + case .p_cookiesReady_set: return "[set] .cookiesReady" + case .p_updatingCookies_get: return "[get] .updatingCookies" + case .p_updatingCookies_set: return "[set] .updatingCookies" + case .p_errorMessage_get: return "[get] .errorMessage" + case .p_errorMessage_set: return "[set] .errorMessage" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { + return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} + public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } + public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } + public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } + public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } + public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } + public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } + public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { + return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) } } diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift index 5b9d946fa..e242b58d9 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift @@ -150,7 +150,7 @@ public struct DiscussionTopicsView: View { } .navigationBarHidden(false) .navigationBarBackButtonHidden(false) - .navigationTitle(DiscussionLocalization.title) + .navigationTitle(viewModel.title) .background( Theme.Colors.background .ignoresSafeArea() diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift index 85ec229e8..0366e7a44 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift @@ -17,7 +17,7 @@ public class DiscussionTopicsViewModel: ObservableObject { @Published var showError: Bool = false @Published var discussionTopics: [DiscussionTopic]? @Published var courseID: String = "" - private var title: String + let title: String var errorMessage: String? { didSet { diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index fad4812d4..336d50a14 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -2679,6 +2679,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func addToDownloadQueue(blocks: [CourseBlock]) throws { + addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + do { + _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + open func getDownloadTasks() -> [DownloadDataTask] { addInvocation(.m_getDownloadTasks) let perform = methodPerformValue(.m_getDownloadTasks) as? () -> Void @@ -2746,6 +2759,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } + open func cancelAllDownloading() throws { + addInvocation(.m_cancelAllDownloading) + let perform = methodPerformValue(.m_cancelAllDownloading) as? () -> Void + perform?() + do { + _ = try methodReturnValue(.m_cancelAllDownloading).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + open func deleteFile(blocks: [CourseBlock]) { addInvocation(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) let perform = methodPerformValue(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void @@ -2771,12 +2797,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } - open func addToDownloadQueue(blocks: [CourseBlock]) throws { - addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) - let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void - perform?(`blocks`) + open func resumeDownloading() throws { + addInvocation(.m_resumeDownloading) + let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void + perform?() do { - _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + _ = try methodReturnValue(.m_resumeDownloading).casted() as Void } catch MockError.notStubed { // do nothing } catch { @@ -2798,34 +2824,22 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } - open func resumeDownloading() throws { - addInvocation(.m_resumeDownloading) - let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void - perform?() - do { - _ = try methodReturnValue(.m_resumeDownloading).casted() as Void - } catch MockError.notStubed { - // do nothing - } catch { - throw error - } - } - fileprivate enum MethodType { case m_publisher case m_eventPublisher + case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) case m_getDownloadTasks case m_getDownloadTasksForCourse__courseId(Parameter) case m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter, Parameter<[CourseBlock]>) case m_cancelDownloading__task_task(Parameter) case m_cancelDownloading__courseId_courseId(Parameter) + case m_cancelAllDownloading case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) - case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) - case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case m_resumeDownloading + case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -2834,6 +2848,11 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case (.m_eventPublisher, .m_eventPublisher): return .match + case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + case (.m_getDownloadTasks, .m_getDownloadTasks): return .match case (.m_getDownloadTasksForCourse__courseId(let lhsCourseid), .m_getDownloadTasksForCourse__courseId(let rhsCourseid)): @@ -2857,6 +2876,8 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) return Matcher.ComparisonResult(results) + case (.m_cancelAllDownloading, .m_cancelAllDownloading): return .match + case (.m_deleteFile__blocks_blocks(let lhsBlocks), .m_deleteFile__blocks_blocks(let rhsBlocks)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) @@ -2869,17 +2890,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) return Matcher.ComparisonResult(results) - case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) - return Matcher.ComparisonResult(results) + case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) return Matcher.ComparisonResult(results) - - case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } @@ -2889,17 +2905,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { switch self { case .m_publisher: return 0 case .m_eventPublisher: return 0 + case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue case .m_getDownloadTasks: return 0 case let .m_getDownloadTasksForCourse__courseId(p0): return p0.intValue case let .m_cancelDownloading__courseId_courseIdblocks_blocks(p0, p1): return p0.intValue + p1.intValue case let .m_cancelDownloading__task_task(p0): return p0.intValue case let .m_cancelDownloading__courseId_courseId(p0): return p0.intValue + case .m_cancelAllDownloading: return 0 case let .m_deleteFile__blocks_blocks(p0): return p0.intValue case .m_deleteAllFiles: return 0 case let .m_fileUrl__for_blockId(p0): return p0.intValue - case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue - case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .m_resumeDownloading: return 0 + case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .p_currentDownloadTask_get: return 0 } } @@ -2907,17 +2924,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { switch self { case .m_publisher: return ".publisher()" case .m_eventPublisher: return ".eventPublisher()" + case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" case .m_getDownloadTasks: return ".getDownloadTasks()" case .m_getDownloadTasksForCourse__courseId: return ".getDownloadTasksForCourse(_:)" case .m_cancelDownloading__courseId_courseIdblocks_blocks: return ".cancelDownloading(courseId:blocks:)" case .m_cancelDownloading__task_task: return ".cancelDownloading(task:)" case .m_cancelDownloading__courseId_courseId: return ".cancelDownloading(courseId:)" + case .m_cancelAllDownloading: return ".cancelAllDownloading()" case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" - case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" - case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .m_resumeDownloading: return ".resumeDownloading()" + case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } @@ -2995,6 +3013,16 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { return Given(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -3025,12 +3053,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { - return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + public static func cancelAllDownloading(willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelAllDownloading, products: willThrow.map({ StubProduct.throw($0) })) } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + public static func cancelAllDownloading(willProduce: (StubberThrows) -> Void) -> MethodStub { let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let given: Given = { return Given(method: .m_cancelAllDownloading, products: willThrow.map({ StubProduct.throw($0) })) }() let stubber = given.stubThrows(for: (Void).self) willProduce(stubber) return given @@ -3052,17 +3080,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func publisher() -> Verify { return Verify(method: .m_publisher)} public static func eventPublisher() -> Verify { return Verify(method: .m_eventPublisher)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} public static func getDownloadTasks() -> Verify { return Verify(method: .m_getDownloadTasks)} public static func getDownloadTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadTasksForCourse__courseId(`courseId`))} public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`))} public static func cancelDownloading(task: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__task_task(`task`))} public static func cancelDownloading(courseId: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseId(`courseId`))} + public static func cancelAllDownloading() -> Verify { return Verify(method: .m_cancelAllDownloading)} public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} public static func deleteAllFiles() -> Verify { return Verify(method: .m_deleteAllFiles)} public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} - public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } @@ -3076,6 +3105,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func eventPublisher(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_eventPublisher, performs: perform) } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + } public static func getDownloadTasks(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_getDownloadTasks, performs: perform) } @@ -3091,6 +3123,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func cancelDownloading(courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_cancelDownloading__courseId_courseId(`courseId`), performs: perform) } + public static func cancelAllDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_cancelAllDownloading, performs: perform) + } public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) } @@ -3100,14 +3135,257 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { - return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_resumeDownloading, performs: perform) } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), performs: perform) } - public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_resumeDownloading, performs: perform) + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + +// MARK: - WebviewCookiesUpdateProtocol + +open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + public var authInteractor: AuthInteractorProtocol { + get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } + } + private var __p_authInteractor: (AuthInteractorProtocol)? + + public var cookiesReady: Bool { + get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } + set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } + } + private var __p_cookiesReady: (Bool)? + + public var updatingCookies: Bool { + get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } + set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } + } + private var __p_updatingCookies: (Bool)? + + public var errorMessage: String? { + get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } + set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } + } + private var __p_errorMessage: (String)? + + + + + + open func updateCookies(force: Bool, retryCount: Int) { + addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) + let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void + perform?(`force`, `retryCount`) + } + + + fileprivate enum MethodType { + case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) + case p_authInteractor_get + case p_cookiesReady_get + case p_cookiesReady_set(Parameter) + case p_updatingCookies_get + case p_updatingCookies_set(Parameter) + case p_errorMessage_get + case p_errorMessage_set(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) + return Matcher.ComparisonResult(results) + case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match + case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match + case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue + case .p_authInteractor_get: return 0 + case .p_cookiesReady_get: return 0 + case .p_cookiesReady_set(let newValue): return newValue.intValue + case .p_updatingCookies_get: return 0 + case .p_updatingCookies_set(let newValue): return newValue.intValue + case .p_errorMessage_get: return 0 + case .p_errorMessage_set(let newValue): return newValue.intValue + } + } + func assertionName() -> String { + switch self { + case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" + case .p_authInteractor_get: return "[get] .authInteractor" + case .p_cookiesReady_get: return "[get] .cookiesReady" + case .p_cookiesReady_set: return "[set] .cookiesReady" + case .p_updatingCookies_get: return "[get] .updatingCookies" + case .p_updatingCookies_set: return "[set] .updatingCookies" + case .p_errorMessage_get: return "[get] .errorMessage" + case .p_errorMessage_set: return "[set] .errorMessage" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { + return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} + public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } + public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } + public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } + public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } + public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } + public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } + public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { + return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) } } diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 9c61fc679..74074812d 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -186,7 +186,8 @@ class ScreenAssembly: Assembly { } container.register(ProfileViewModel.self) { r in ProfileViewModel( - interactor: r.resolve(ProfileInteractorProtocol.self)!, + interactor: r.resolve(ProfileInteractorProtocol.self)!, + downloadManager: r.resolve(DownloadManagerProtocol.self)!, router: r.resolve(ProfileRouter.self)!, analytics: r.resolve(ProfileAnalytics.self)!, config: r.resolve(ConfigProtocol.self)!, diff --git a/OpenEdX/Data/CorePersistence.swift b/OpenEdX/Data/CorePersistence.swift index 1070011dd..8af067d07 100644 --- a/OpenEdX/Data/CorePersistence.swift +++ b/OpenEdX/Data/CorePersistence.swift @@ -12,74 +12,68 @@ import Combine public class CorePersistence: CorePersistenceProtocol { - private var context: NSManagedObjectContext + // MARK: - Predicate - public init(context: NSManagedObjectContext) { - self.context = context - } + enum CDPredicate { + case id(String) + case courseId(String) + case state(String) - public func publisher() -> AnyPublisher { - let notification = NSManagedObjectContext.didChangeObjectsNotification - return NotificationCenter.default.publisher(for: notification, object: context) - .compactMap({ notification in - guard let userInfo = notification.userInfo else { return nil } + var predicate: NSPredicate { + switch self { + case .id(let id): + NSPredicate(format: "id = %@", id) + case .courseId(let courseId): + NSPredicate(format: "courseId = %@", courseId) + case .state(let state): + NSPredicate(format: "state != %@", state) + } + } + } - if let inserts = userInfo[NSInsertedObjectsKey] as? Set, inserts.count > 0 { - return inserts.count - } + // MARK: - Properties - if let updates = userInfo[NSUpdatedObjectsKey] as? Set, updates.count > 0 { - return updates.count - } + private var context: NSManagedObjectContext + private var userId: Int? - if let deletes = userInfo[NSDeletedObjectsKey] as? Set, deletes.count > 0 { - return deletes.count - } + public init(context: NSManagedObjectContext) { + self.context = context + } - return nil - }) - .eraseToAnyPublisher() + public func set(userId: Int) { + self.userId = userId } - public func getDownloadDataTasks(completion: @escaping ([DownloadDataTask]) -> Void) { - context.performAndWait { - let request = CDDownloadData.fetchRequest() - guard let downloadData = try? context.fetch(request) else { - completion([]) - return - } - let downloads = downloadData.map { - DownloadDataTask( - id: $0.id ?? "", - courseId: $0.courseId ?? "", - url: $0.url ?? "", - fileName: $0.fileName ?? "", - displayName: $0.displayName ?? "", - progress: $0.progress, - resumeData: $0.resumeData, - state: DownloadState(rawValue: $0.state ?? "") ?? .waiting, - type: DownloadType(rawValue: $0.type ?? "") ?? .video, - fileSize: Int($0.fileSize) - ) - } - completion(downloads) - } + public func getUserID() -> Int? { + userId } - public func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) { + // MARK: - Public Intents + + public func addToDownloadQueue( + blocks: [CourseBlock], + downloadQuality: DownloadQuality + ) { for block in blocks { - let request = CDDownloadData.fetchRequest() - request.predicate = NSPredicate(format: "id = %@", block.id) - guard (try? context.fetch(request).first) == nil else { continue } + let downloadDataId = downloadDataId(from: block.id) + + let data = try? fetchCDDownloadData( + predicate: CDPredicate.id(downloadDataId) + ) + guard data?.first == nil else { continue } + guard let video = block.encodedVideo?.video(downloadQuality: downloadQuality), let url = video.url, let fileExtension = URL(string: url)?.pathExtension else { continue } + let fileName = "\(block.id).\(fileExtension)" context.performAndWait { let newDownloadData = CDDownloadData(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - newDownloadData.id = block.id + newDownloadData.id = downloadDataId + newDownloadData.blockId = block.id + newDownloadData.userId = getUserId32() ?? 0 newDownloadData.courseId = block.courseId newDownloadData.url = url newDownloadData.fileName = fileName @@ -93,101 +87,105 @@ public class CorePersistence: CorePersistenceProtocol { } } - public func getNextBlockForDownloading() -> DownloadDataTask? { - let request = CDDownloadData.fetchRequest() - request.predicate = NSPredicate(format: "state != %@", DownloadState.finished.rawValue) - request.fetchLimit = 1 - guard let data = try? context.fetch(request).first else { return nil } - return DownloadDataTask( - id: data.id ?? "", - courseId: data.courseId ?? "", - url: data.url ?? "", - fileName: data.fileName ?? "", - displayName: data.displayName ?? "", - progress: data.progress, - resumeData: data.resumeData, - state: DownloadState(rawValue: data.state ?? "") ?? .waiting, - type: DownloadType(rawValue: data.type ?? "" ) ?? .video, - fileSize: Int(data.fileSize) - ) + public func getDownloadDataTasks(completion: @escaping ([DownloadDataTask]) -> Void) { + context.performAndWait { + guard let data = try? fetchCDDownloadData() else { + completion([]) + return + } + + let downloads = data.downloadDataTasks() + + completion(downloads) + } } - public func getDownloadDataTasksForCourse(_ courseId: String, completion: @escaping ([DownloadDataTask]) -> Void) { + public func getDownloadDataTasksForCourse( + _ courseId: String, + completion: @escaping ([DownloadDataTask]) -> Void + ) { context.performAndWait { - let request = CDDownloadData.fetchRequest() - request.predicate = NSPredicate(format: "courseId = %@", courseId) - guard let downloadData = try? context.fetch(request) else { + guard let data = try? fetchCDDownloadData( + predicate: .courseId(courseId) + ) else { completion([]) return } - let downloads = downloadData.map { - DownloadDataTask( - id: $0.id ?? "", - courseId: $0.courseId ?? "", - url: $0.url ?? "", - fileName: $0.fileName ?? "", - displayName: $0.displayName ?? "", - progress: $0.progress, - resumeData: $0.resumeData, - state: DownloadState(rawValue: $0.state ?? "") ?? .waiting, - type: DownloadType(rawValue: $0.type ?? "") ?? .video, - fileSize: Int($0.fileSize) - ) + + if data.isEmpty { + completion([]) + return } + + let downloads = data + .downloadDataTasks() + .filter(userId: userId) + completion(downloads) } } - public func downloadDataTask(for blockId: String, completion: @escaping (DownloadDataTask?) -> Void) { + public func downloadDataTask( + for blockId: String, + completion: @escaping (DownloadDataTask?) -> Void + ) { context.performAndWait { - let request = CDDownloadData.fetchRequest() - request.predicate = NSPredicate(format: "id = %@", blockId) - guard let downloadData = try? context.fetch(request).first else { + let data = try? fetchCDDownloadData( + predicate: .id(downloadDataId(from: blockId)) + ) + + guard let downloadData = data?.first else { completion(nil) return } - let data = DownloadDataTask( - id: downloadData.id ?? "", - courseId: downloadData.courseId ?? "", - url: downloadData.url ?? "", - fileName: downloadData.fileName ?? "", - displayName: downloadData.displayName ?? "", - progress: downloadData.progress, - resumeData: downloadData.resumeData, - state: DownloadState(rawValue: downloadData.state ?? "") ?? .waiting, - type: DownloadType(rawValue: downloadData.type ?? "" ) ?? .video, - fileSize: Int(downloadData.fileSize) - ) - completion(data) + + let downloadDataTask = DownloadDataTask(sourse: downloadData) + + completion(downloadDataTask) } } public func downloadDataTask(for blockId: String) -> DownloadDataTask? { - let request = CDDownloadData.fetchRequest() - request.predicate = NSPredicate(format: "id = %@", blockId) - guard let downloadData = try? context.fetch(request).first else { return nil } - return DownloadDataTask( - id: downloadData.id ?? "", - courseId: downloadData.courseId ?? "", - url: downloadData.url ?? "", - fileName: downloadData.fileName ?? "", - displayName: downloadData.displayName ?? "", - progress: downloadData.progress, - resumeData: downloadData.resumeData, - state: DownloadState(rawValue: downloadData.state ?? "") ?? .waiting, - type: DownloadType(rawValue: downloadData.type ?? "" ) ?? .video, - fileSize: Int(downloadData.fileSize) + let data = try? fetchCDDownloadData( + predicate: .id(downloadDataId(from: blockId)) + ) + + guard let downloadData = data?.first else { return nil } + + return DownloadDataTask(sourse: downloadData) + } + + public func nextBlockForDownloading() -> DownloadDataTask? { + let data = try? fetchCDDownloadData( + predicate: .state(DownloadState.finished.rawValue), + fetchLimit: 1 ) + + guard let downloadData = data?.first else { + return nil + } + + return DownloadDataTask(sourse: downloadData) } - public func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) { + public func updateDownloadState( + id: String, + state: DownloadState, + resumeData: Data? + ) { context.performAndWait { - let request = CDDownloadData.fetchRequest() - request.predicate = NSPredicate(format: "id = %@", id) - guard let downloadData = try? context.fetch(request).first else { return } - downloadData.state = state.rawValue - if state == .finished { downloadData.progress = 1 } - downloadData.resumeData = resumeData + guard let data = try? fetchCDDownloadData( + predicate: .id(downloadDataId(from: id)) + ) else { + return + } + + guard let task = data.first else { return } + + task.state = state.rawValue + if state == .finished { task.progress = 1 } + task.resumeData = resumeData + do { try context.save() } catch { @@ -198,33 +196,39 @@ public class CorePersistence: CorePersistenceProtocol { public func deleteDownloadDataTask(id: String) throws { context.performAndWait { - let request = CDDownloadData.fetchRequest() - request.predicate = NSPredicate(format: "id = %@", id) do { - let records = try context.fetch(request) + let records = try fetchCDDownloadData( + predicate: .id(downloadDataId(from: id)) + ) + for record in records { context.delete(record) try context.save() debugLog("File erased successfully") } + } catch { debugLog("Error fetching records: \(error.localizedDescription)") } } } - public func saveDownloadDataTask(data: DownloadDataTask) { + public func saveDownloadDataTask(_ task: DownloadDataTask) { context.performAndWait { let newDownloadData = CDDownloadData(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - newDownloadData.id = data.id - newDownloadData.courseId = data.courseId - newDownloadData.url = data.url - newDownloadData.progress = data.progress - newDownloadData.fileName = data.fileName - newDownloadData.resumeData = data.resumeData - newDownloadData.state = data.state.rawValue - newDownloadData.fileSize = Int32(data.fileSize) + newDownloadData.id = task.id + newDownloadData.blockId = task.blockId + newDownloadData.userId = Int32(task.userId) + newDownloadData.courseId = task.courseId + newDownloadData.url = task.url + newDownloadData.progress = task.progress + newDownloadData.fileName = task.fileName + newDownloadData.displayName = task.displayName + newDownloadData.resumeData = task.resumeData + newDownloadData.state = task.state.rawValue + newDownloadData.type = task.type.rawValue + newDownloadData.fileSize = Int32(task.fileSize) do { try context.save() @@ -233,4 +237,84 @@ public class CorePersistence: CorePersistenceProtocol { } } } + + public func publisher() -> AnyPublisher { + let notification = NSManagedObjectContext.didChangeObjectsNotification + return NotificationCenter.default.publisher(for: notification, object: context) + .compactMap({ notification in + guard let userInfo = notification.userInfo else { return nil } + + if let inserts = userInfo[NSInsertedObjectsKey] as? Set, inserts.count > 0 { + return inserts.count + } + + if let updates = userInfo[NSUpdatedObjectsKey] as? Set, updates.count > 0 { + return updates.count + } + + if let deletes = userInfo[NSDeletedObjectsKey] as? Set, deletes.count > 0 { + return deletes.count + } + + return nil + }) + .eraseToAnyPublisher() + } + + // MARK: - Private Intents + + private func fetchCDDownloadData( + predicate: CDPredicate? = nil, + fetchLimit: Int? = nil + ) throws -> [CDDownloadData] { + let request = CDDownloadData.fetchRequest() + if let predicate = predicate { + request.predicate = predicate.predicate + } + if let fetchLimit = fetchLimit { + request.fetchLimit = fetchLimit + } + let data = try context.fetch(request).filter { + guard let userId = getUserId32() else { + return true + } + debugLog(userId, "-userId-") + return $0.userId == userId + } + return data + } + + private func getUserId32() -> Int32? { + guard let userId else { + return nil + } + return Int32(userId) + } + + private func downloadDataId(from id: String) -> String { + guard let userId else { + return id + } + if id.contains(String(userId)) { + return id + } + return "\(userId)_\(id)" + } +} + +extension Array where Element == DownloadDataTask { + func filter(userId: Int?) -> [DownloadDataTask] { + filter { + guard let userId else { + return true + } + return $0.userId == userId + } + } +} + +extension Array where Element == CDDownloadData { + func downloadDataTasks() -> [DownloadDataTask] { + compactMap { DownloadDataTask(sourse: $0) } + } } diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 10a267a53..582a2772b 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -60,16 +60,22 @@ public class Router: AuthorizationRouter, public func showMainOrWhatsNewScreen(sourceScreen: LogistrationSourceScreen) { showToolBar() - var storage = Container.shared.resolve(WhatsNewStorage.self)! + var whatsNewStorage = Container.shared.resolve(WhatsNewStorage.self)! let config = Container.shared.resolve(ConfigProtocol.self)! + let persistence = Container.shared.resolve(CorePersistenceProtocol.self)! + let coreStorage = Container.shared.resolve(CoreStorage.self)! - let viewModel = WhatsNewViewModel(storage: storage, sourceScreen: sourceScreen) + if let userId = coreStorage.user?.id { + persistence.set(userId: userId) + } + + let viewModel = WhatsNewViewModel(storage: whatsNewStorage, sourceScreen: sourceScreen) let whatsNew = WhatsNewView(router: Container.shared.resolve(WhatsNewRouter.self)!, viewModel: viewModel) let shouldShowWhatsNew = viewModel.shouldShowWhatsNew() if shouldShowWhatsNew && config.features.whatNewEnabled { if let jsonVersion = viewModel.getVersion() { - storage.whatsNewVersion = jsonVersion + whatsNewStorage.whatsNewVersion = jsonVersion } let controller = UIHostingController(rootView: whatsNew) navigationController.viewControllers = [controller] diff --git a/Profile/Profile/Data/ProfileRepository.swift b/Profile/Profile/Data/ProfileRepository.swift index 83f37215a..7608ff849 100644 --- a/Profile/Profile/Data/ProfileRepository.swift +++ b/Profile/Profile/Data/ProfileRepository.swift @@ -71,8 +71,6 @@ public class ProfileRepository: ProfileRepositoryProtocol { ProfileEndpoint.logOut(refreshToken: refreshToken, clientID: config.oAuthClientId) ) storage.clear() - await downloadManager.deleteAllFiles() - coreDataHandler.clear() } public func getSpokenLanguages() -> [PickerFields.Option] { diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index 15b78dc88..6354d5999 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -239,11 +239,14 @@ public struct ProfileView: View { struct ProfileView_Previews: PreviewProvider { static var previews: some View { let router = ProfileRouterMock() - let vm = ProfileViewModel(interactor: ProfileInteractor.mock, - router: router, - analytics: ProfileAnalyticsMock(), - config: ConfigMock(), - connectivity: Connectivity()) + let vm = ProfileViewModel( + interactor: ProfileInteractor.mock, + downloadManager: DownloadManagerMock(), + router: router, + analytics: ProfileAnalyticsMock(), + config: ConfigMock(), + connectivity: Connectivity() + ) ProfileView(viewModel: vm, settingsTapped: .constant(false)) .preferredColorScheme(.light) diff --git a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift index ae3d9a309..a502bd138 100644 --- a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift +++ b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift @@ -40,16 +40,19 @@ public class ProfileViewModel: ObservableObject { let connectivity: ConnectivityProtocol private let interactor: ProfileInteractorProtocol + private let downloadManager: DownloadManagerProtocol private let analytics: ProfileAnalytics public init( interactor: ProfileInteractorProtocol, + downloadManager: DownloadManagerProtocol, router: ProfileRouter, analytics: ProfileAnalytics, config: ConfigProtocol, connectivity: ConnectivityProtocol ) { self.interactor = interactor + self.downloadManager = downloadManager self.router = router self.analytics = analytics self.config = config @@ -120,6 +123,7 @@ public class ProfileViewModel: ObservableObject { @MainActor func logOut() async { try? await interactor.logOut() + try? await downloadManager.cancelAllDownloading() router.showStartupScreen() analytics.userLogout(force: false) } diff --git a/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift b/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift index c6888fcaf..40e56bbfa 100644 --- a/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift @@ -92,6 +92,7 @@ final class ProfileViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() let viewModel = ProfileViewModel( interactor: interactor, + downloadManager: DownloadManagerMock(), router: router, analytics: analytics, config: ConfigMock(), @@ -130,6 +131,7 @@ final class ProfileViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() let viewModel = ProfileViewModel( interactor: interactor, + downloadManager: DownloadManagerMock(), router: router, analytics: analytics, config: ConfigMock(), @@ -167,6 +169,7 @@ final class ProfileViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() let viewModel = ProfileViewModel( interactor: interactor, + downloadManager: DownloadManagerMock(), router: router, analytics: analytics, config: ConfigMock(), @@ -206,6 +209,7 @@ final class ProfileViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() let viewModel = ProfileViewModel( interactor: interactor, + downloadManager: DownloadManagerMock(), router: router, analytics: analytics, config: ConfigMock(), @@ -231,6 +235,7 @@ final class ProfileViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() let viewModel = ProfileViewModel( interactor: interactor, + downloadManager: DownloadManagerMock(), router: router, analytics: analytics, config: ConfigMock(), @@ -252,6 +257,7 @@ final class ProfileViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() let viewModel = ProfileViewModel( interactor: interactor, + downloadManager: DownloadManagerMock(), router: router, analytics: analytics, config: ConfigMock(), @@ -269,7 +275,8 @@ final class ProfileViewModelTests: XCTestCase { let analytics = ProfileAnalyticsMock() let connectivity = ConnectivityProtocolMock() let viewModel = ProfileViewModel( - interactor: interactor, + interactor: interactor, + downloadManager: DownloadManagerMock(), router: router, analytics: analytics, config: ConfigMock(), @@ -287,7 +294,8 @@ final class ProfileViewModelTests: XCTestCase { let analytics = ProfileAnalyticsMock() let connectivity = ConnectivityProtocolMock() let viewModel = ProfileViewModel( - interactor: interactor, + interactor: interactor, + downloadManager: DownloadManagerMock(), router: router, analytics: analytics, config: ConfigMock(), @@ -305,7 +313,8 @@ final class ProfileViewModelTests: XCTestCase { let analytics = ProfileAnalyticsMock() let connectivity = ConnectivityProtocolMock() let viewModel = ProfileViewModel( - interactor: interactor, + interactor: interactor, + downloadManager: DownloadManagerMock(), router: router, analytics: analytics, config: ConfigMock(), @@ -323,7 +332,8 @@ final class ProfileViewModelTests: XCTestCase { let analytics = ProfileAnalyticsMock() let connectivity = ConnectivityProtocolMock() let viewModel = ProfileViewModel( - interactor: interactor, + interactor: interactor, + downloadManager: DownloadManagerMock(), router: router, analytics: analytics, config: ConfigMock(), diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 047a50d8d..19f41b288 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -1190,6 +1190,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func addToDownloadQueue(blocks: [CourseBlock]) throws { + addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + do { + _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + open func getDownloadTasks() -> [DownloadDataTask] { addInvocation(.m_getDownloadTasks) let perform = methodPerformValue(.m_getDownloadTasks) as? () -> Void @@ -1257,6 +1270,19 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } + open func cancelAllDownloading() throws { + addInvocation(.m_cancelAllDownloading) + let perform = methodPerformValue(.m_cancelAllDownloading) as? () -> Void + perform?() + do { + _ = try methodReturnValue(.m_cancelAllDownloading).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + open func deleteFile(blocks: [CourseBlock]) { addInvocation(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) let perform = methodPerformValue(.m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void @@ -1282,12 +1308,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } - open func addToDownloadQueue(blocks: [CourseBlock]) throws { - addInvocation(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) - let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void - perform?(`blocks`) + open func resumeDownloading() throws { + addInvocation(.m_resumeDownloading) + let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void + perform?() do { - _ = try methodReturnValue(.m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + _ = try methodReturnValue(.m_resumeDownloading).casted() as Void } catch MockError.notStubed { // do nothing } catch { @@ -1309,34 +1335,22 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } - open func resumeDownloading() throws { - addInvocation(.m_resumeDownloading) - let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void - perform?() - do { - _ = try methodReturnValue(.m_resumeDownloading).casted() as Void - } catch MockError.notStubed { - // do nothing - } catch { - throw error - } - } - fileprivate enum MethodType { case m_publisher case m_eventPublisher + case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) case m_getDownloadTasks case m_getDownloadTasksForCourse__courseId(Parameter) case m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter, Parameter<[CourseBlock]>) case m_cancelDownloading__task_task(Parameter) case m_cancelDownloading__courseId_courseId(Parameter) + case m_cancelAllDownloading case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) - case m_addToDownloadQueue__blocks_blocks(Parameter<[CourseBlock]>) - case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case m_resumeDownloading + case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -1345,6 +1359,11 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case (.m_eventPublisher, .m_eventPublisher): return .match + case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + case (.m_getDownloadTasks, .m_getDownloadTasks): return .match case (.m_getDownloadTasksForCourse__courseId(let lhsCourseid), .m_getDownloadTasksForCourse__courseId(let rhsCourseid)): @@ -1368,6 +1387,8 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) return Matcher.ComparisonResult(results) + case (.m_cancelAllDownloading, .m_cancelAllDownloading): return .match + case (.m_deleteFile__blocks_blocks(let lhsBlocks), .m_deleteFile__blocks_blocks(let rhsBlocks)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) @@ -1380,17 +1401,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) return Matcher.ComparisonResult(results) - case (.m_addToDownloadQueue__blocks_blocks(let lhsBlocks), .m_addToDownloadQueue__blocks_blocks(let rhsBlocks)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) - return Matcher.ComparisonResult(results) + case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) return Matcher.ComparisonResult(results) - - case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } @@ -1400,17 +1416,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { switch self { case .m_publisher: return 0 case .m_eventPublisher: return 0 + case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue case .m_getDownloadTasks: return 0 case let .m_getDownloadTasksForCourse__courseId(p0): return p0.intValue case let .m_cancelDownloading__courseId_courseIdblocks_blocks(p0, p1): return p0.intValue + p1.intValue case let .m_cancelDownloading__task_task(p0): return p0.intValue case let .m_cancelDownloading__courseId_courseId(p0): return p0.intValue + case .m_cancelAllDownloading: return 0 case let .m_deleteFile__blocks_blocks(p0): return p0.intValue case .m_deleteAllFiles: return 0 case let .m_fileUrl__for_blockId(p0): return p0.intValue - case let .m_addToDownloadQueue__blocks_blocks(p0): return p0.intValue - case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .m_resumeDownloading: return 0 + case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .p_currentDownloadTask_get: return 0 } } @@ -1418,17 +1435,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { switch self { case .m_publisher: return ".publisher()" case .m_eventPublisher: return ".eventPublisher()" + case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" case .m_getDownloadTasks: return ".getDownloadTasks()" case .m_getDownloadTasksForCourse__courseId: return ".getDownloadTasksForCourse(_:)" case .m_cancelDownloading__courseId_courseIdblocks_blocks: return ".cancelDownloading(courseId:blocks:)" case .m_cancelDownloading__task_task: return ".cancelDownloading(task:)" case .m_cancelDownloading__courseId_courseId: return ".cancelDownloading(courseId:)" + case .m_cancelAllDownloading: return ".cancelAllDownloading()" case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" - case .m_addToDownloadQueue__blocks_blocks: return ".addToDownloadQueue(blocks:)" - case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .m_resumeDownloading: return ".resumeDownloading()" + case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } @@ -1506,6 +1524,16 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { return Given(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -1536,12 +1564,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willThrow: Error...) -> MethodStub { - return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) + public static func cancelAllDownloading(willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelAllDownloading, products: willThrow.map({ StubProduct.throw($0) })) } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + public static func cancelAllDownloading(willProduce: (StubberThrows) -> Void) -> MethodStub { let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let given: Given = { return Given(method: .m_cancelAllDownloading, products: willThrow.map({ StubProduct.throw($0) })) }() let stubber = given.stubThrows(for: (Void).self) willProduce(stubber) return given @@ -1563,17 +1591,18 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func publisher() -> Verify { return Verify(method: .m_publisher)} public static func eventPublisher() -> Verify { return Verify(method: .m_eventPublisher)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} public static func getDownloadTasks() -> Verify { return Verify(method: .m_getDownloadTasks)} public static func getDownloadTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadTasksForCourse__courseId(`courseId`))} public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`))} public static func cancelDownloading(task: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__task_task(`task`))} public static func cancelDownloading(courseId: Parameter) -> Verify { return Verify(method: .m_cancelDownloading__courseId_courseId(`courseId`))} + public static func cancelAllDownloading() -> Verify { return Verify(method: .m_cancelAllDownloading)} public static func deleteFile(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_deleteFile__blocks_blocks(`blocks`))} public static func deleteAllFiles() -> Verify { return Verify(method: .m_deleteAllFiles)} public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocks(`blocks`))} - public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } @@ -1587,6 +1616,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func eventPublisher(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_eventPublisher, performs: perform) } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + } public static func getDownloadTasks(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_getDownloadTasks, performs: perform) } @@ -1602,6 +1634,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func cancelDownloading(courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_cancelDownloading__courseId_courseId(`courseId`), performs: perform) } + public static func cancelAllDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_cancelAllDownloading, performs: perform) + } public static func deleteFile(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_deleteFile__blocks_blocks(`blocks`), performs: perform) } @@ -1611,15 +1646,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { - return Perform(method: .m_addToDownloadQueue__blocks_blocks(`blocks`), performs: perform) + public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_resumeDownloading, performs: perform) } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), performs: perform) } - public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_resumeDownloading, performs: perform) - } } public func given(_ method: Given) { @@ -3086,3 +3118,249 @@ open class ProfileRouterMock: ProfileRouter, Mock { } } +// MARK: - WebviewCookiesUpdateProtocol + +open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + public var authInteractor: AuthInteractorProtocol { + get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } + } + private var __p_authInteractor: (AuthInteractorProtocol)? + + public var cookiesReady: Bool { + get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } + set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } + } + private var __p_cookiesReady: (Bool)? + + public var updatingCookies: Bool { + get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } + set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } + } + private var __p_updatingCookies: (Bool)? + + public var errorMessage: String? { + get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } + set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } + } + private var __p_errorMessage: (String)? + + + + + + open func updateCookies(force: Bool, retryCount: Int) { + addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) + let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void + perform?(`force`, `retryCount`) + } + + + fileprivate enum MethodType { + case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) + case p_authInteractor_get + case p_cookiesReady_get + case p_cookiesReady_set(Parameter) + case p_updatingCookies_get + case p_updatingCookies_set(Parameter) + case p_errorMessage_get + case p_errorMessage_set(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) + return Matcher.ComparisonResult(results) + case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match + case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match + case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match + case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue + case .p_authInteractor_get: return 0 + case .p_cookiesReady_get: return 0 + case .p_cookiesReady_set(let newValue): return newValue.intValue + case .p_updatingCookies_get: return 0 + case .p_updatingCookies_set(let newValue): return newValue.intValue + case .p_errorMessage_get: return 0 + case .p_errorMessage_set(let newValue): return newValue.intValue + } + } + func assertionName() -> String { + switch self { + case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" + case .p_authInteractor_get: return "[get] .authInteractor" + case .p_cookiesReady_get: return "[get] .cookiesReady" + case .p_cookiesReady_set: return "[set] .cookiesReady" + case .p_updatingCookies_get: return "[get] .updatingCookies" + case .p_updatingCookies_set: return "[set] .updatingCookies" + case .p_errorMessage_get: return "[get] .errorMessage" + case .p_errorMessage_set: return "[set] .errorMessage" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { + return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} + public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } + public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } + public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } + public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } + public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } + public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } + public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { + return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + From 7db3b35373c907e2e1e8265594b90a3b5aa61253 Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Fri, 23 Feb 2024 18:11:05 +0500 Subject: [PATCH 030/136] feat: branch sdk integration (#283) * feat: branch sdk integration * fix: fix crash because of unnamed variable * refactor: address review feedback * chore: add associated domains based on config * refactor: adding CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION at correct place * chore: adding default values for Associated Domains --- Core/Core.xcodeproj/project.pbxproj | 21 +++++- .../Configuration/Config/BranchConfig.swift | 3 +- OpenEdX.xcodeproj/project.pbxproj | 6 ++ .../DeepLinkManager/DeepLinkManager.swift | 27 +++++-- .../DeepLinkManager/Link/DeepLink.swift | 48 +++++++++++- .../DeepLinkManager/Link/PushLink.swift | 13 ++-- .../Services/BranchService.swift | 19 ++++- .../PushNotificationsManager.swift | 2 +- OpenEdX/OpenEdX.entitlements | 7 ++ Podfile.lock | 2 +- config_script/process_config.py | 73 +++++++++++++++++-- 11 files changed, 193 insertions(+), 28 deletions(-) diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 1c68a8ad8..28ccc5ca5 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -126,12 +126,13 @@ 07DDFCBD29A780BB00572595 /* UINavigationController+Animation.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07DDFCBC29A780BB00572595 /* UINavigationController+Animation.swift */; }; 07E0939F2B308D2800F1E4B2 /* Data_Certificate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E0939E2B308D2800F1E4B2 /* Data_Certificate.swift */; }; 141F1D302B7328D4009E81EB /* WebviewCookiesUpdateProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 141F1D2F2B7328D4009E81EB /* WebviewCookiesUpdateProtocol.swift */; }; + 142EDD6C2B831D1400F9F320 /* BranchSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 142EDD6B2B831D1400F9F320 /* BranchSDK */; }; A53A32352B233DEC005FE38A /* ThemeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A32342B233DEC005FE38A /* ThemeConfig.swift */; }; A595689B2B6173DF00ED4F90 /* BranchConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A595689A2B6173DF00ED4F90 /* BranchConfig.swift */; }; A5F4E7B52B61544A00ACD166 /* BrazeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */; }; BA30427F2B20B320009B64B7 /* SocialAuthError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA30427D2B20B299009B64B7 /* SocialAuthError.swift */; }; - BA4AFB442B6A5AF100A21367 /* CheckBoxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA4AFB432B6A5AF100A21367 /* CheckBoxView.swift */; }; BA4AFB422B5A7A0900A21367 /* VideoDownloadQualityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA4AFB412B5A7A0900A21367 /* VideoDownloadQualityView.swift */; }; + BA4AFB442B6A5AF100A21367 /* CheckBoxView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA4AFB432B6A5AF100A21367 /* CheckBoxView.swift */; }; BA593F1C2AF8E498009ADB51 /* ScrollSlidingTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA593F1B2AF8E498009ADB51 /* ScrollSlidingTabBar.swift */; }; BA593F1E2AF8E4A0009ADB51 /* FrameReader.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA593F1D2AF8E4A0009ADB51 /* FrameReader.swift */; }; BA76135C2B21BC7300B599B7 /* SocialAuthResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA76135B2B21BC7300B599B7 /* SocialAuthResponse.swift */; }; @@ -307,8 +308,8 @@ A595689A2B6173DF00ED4F90 /* BranchConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BranchConfig.swift; sourceTree = ""; }; A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = BrazeConfig.swift; sourceTree = ""; }; BA30427D2B20B299009B64B7 /* SocialAuthError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SocialAuthError.swift; sourceTree = ""; }; - BA4AFB432B6A5AF100A21367 /* CheckBoxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckBoxView.swift; sourceTree = ""; }; BA4AFB412B5A7A0900A21367 /* VideoDownloadQualityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadQualityView.swift; sourceTree = ""; }; + BA4AFB432B6A5AF100A21367 /* CheckBoxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckBoxView.swift; sourceTree = ""; }; BA593F1B2AF8E498009ADB51 /* ScrollSlidingTabBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ScrollSlidingTabBar.swift; sourceTree = ""; }; BA593F1D2AF8E4A0009ADB51 /* FrameReader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FrameReader.swift; sourceTree = ""; }; BA76135B2B21BC7300B599B7 /* SocialAuthResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialAuthResponse.swift; sourceTree = ""; }; @@ -355,6 +356,7 @@ BAF0D4CB2AD6AE14007AC334 /* FacebookLogin in Frameworks */, 025EF2F62971740000B838AB /* YouTubePlayerKit in Frameworks */, C8C446EF233F81B9FABB77D2 /* Pods_App_Core.framework in Frameworks */, + 142EDD6C2B831D1400F9F320 /* BranchSDK in Frameworks */, BA8FA66C2AD59BBC00EA029A /* GoogleSignIn in Frameworks */, E055A5392B18DC95008D9E5E /* Theme.framework in Frameworks */, ); @@ -870,6 +872,7 @@ 025EF2F52971740000B838AB /* YouTubePlayerKit */, BA8FA66B2AD59BBC00EA029A /* GoogleSignIn */, BAF0D4CA2AD6AE14007AC334 /* FacebookLogin */, + 142EDD6B2B831D1400F9F320 /* BranchSDK */, ); productName = Core; productReference = 0770DE0828D07831006D8A5D /* Core.framework */; @@ -909,6 +912,7 @@ 025EF2F42971740000B838AB /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */, BA8FA65E2AD574D700EA029A /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */, BA8FA6712AD6ABA300EA029A /* XCRemoteSwiftPackageReference "facebook-ios-sdk" */, + 142EDD6A2B831D1400F9F320 /* XCRemoteSwiftPackageReference "ios-branch-sdk-spm" */, ); productRefGroup = 0770DE0928D07831006D8A5D /* Products */; projectDirPath = ""; @@ -2164,6 +2168,14 @@ minimumVersion = 1.5.0; }; }; + 142EDD6A2B831D1400F9F320 /* XCRemoteSwiftPackageReference "ios-branch-sdk-spm" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/BranchMetrics/ios-branch-sdk-spm"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.2.0; + }; + }; BA8FA65E2AD574D700EA029A /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/google/GoogleSignIn-iOS"; @@ -2188,6 +2200,11 @@ package = 025EF2F42971740000B838AB /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */; productName = YouTubePlayerKit; }; + 142EDD6B2B831D1400F9F320 /* BranchSDK */ = { + isa = XCSwiftPackageProductDependency; + package = 142EDD6A2B831D1400F9F320 /* XCRemoteSwiftPackageReference "ios-branch-sdk-spm" */; + productName = BranchSDK; + }; BA8FA66B2AD59BBC00EA029A /* GoogleSignIn */ = { isa = XCSwiftPackageProductDependency; package = BA8FA65E2AD574D700EA029A /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */; diff --git a/Core/Core/Configuration/Config/BranchConfig.swift b/Core/Core/Configuration/Config/BranchConfig.swift index 43faec57f..6f2a785b6 100644 --- a/Core/Core/Configuration/Config/BranchConfig.swift +++ b/Core/Core/Configuration/Config/BranchConfig.swift @@ -18,8 +18,9 @@ public final class BranchConfig: NSObject { init(dictionary: [String: AnyObject]) { super.init() - enabled = dictionary[BranchKeys.enabled] as? Bool == true key = dictionary[BranchKeys.key] as? String + enabled = dictionary[BranchKeys.enabled] as? Bool ?? false && key?.isEmpty == false + } } diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 85704b35b..b8b38fd4a 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -639,6 +639,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -727,6 +728,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -821,6 +823,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -909,6 +912,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -1057,6 +1061,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -1091,6 +1096,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; diff --git a/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift b/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift index 157bf2bb7..677188fee 100644 --- a/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift +++ b/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift @@ -10,15 +10,25 @@ import Core import UIKit public protocol DeepLinkService { - func configureWith(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) - func handledURLWith(app: UIApplication, open url: URL, options: [UIApplication.OpenURLOptionsKey: Any]) -> Bool + func configureWith( + manager: DeepLinkManager, + config: ConfigProtocol, + launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) + + func handledURLWith( + app: UIApplication, + open url: URL, + options: [UIApplication.OpenURLOptionsKey: Any] + ) -> Bool } -class DeepLinkManager { +public class DeepLinkManager { private var services: [DeepLinkService] = [] + private let config: ConfigProtocol - // Init manager public init(config: ConfigProtocol) { + self.config = config services = servicesFor(config: config) } @@ -39,7 +49,7 @@ class DeepLinkManager { // Configure services func configureDeepLinkService(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { for service in services { - service.configureWith(launchOptions: launchOptions) + service.configureWith(manager: self, config: config, launchOptions: launchOptions) } } @@ -61,8 +71,11 @@ class DeepLinkManager { } // This method process the deep link with response parameters - func processDeepLink(with params: [String: Any]) { - if anyServiceEnabled { + func processDeepLink(with params: [AnyHashable: Any]?) { + guard let params = params else { return } + + let deeplink = DeepLink(dictionary: params) + if anyServiceEnabled && deeplink.type != .none { // redirect if possible } } diff --git a/OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift b/OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift index 3df8a21e6..8f7f3a407 100644 --- a/OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift +++ b/OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift @@ -6,13 +6,57 @@ // import Foundation +import Core enum DeepLinkType: String { + case courseDashboard = "course_dashboard" + case courseVideos = "course_videos" + case discussions = "course_discussion" + case courseDates = "course_dates" + case courseHandout = "course_handout" + case courseComponent = "course_component" + case courseAnnouncement = "course_announcement" + case discussionTopic = "discussion_topic" + case discussionPost = "discussion_post" + case discussionComment = "discussion_comment" + case discovery = "discovery" + case discoveryCourseDetail = "discovery_course_detail" + case discoveryProgramDetail = "discovery_program_detail" + case program = "program" + case programDetail = "program_detail" + case userProfile = "user_profile" + case profile = "profile" case none } +private enum DeepLinkKeys: String, RawStringExtractable { + case courseID = "course_id" + case pathID = "path_id" + case screenName = "screen_name" + case topicID = "topic_id" + case threadID = "thread_id" + case commentID = "comment_id" + case componentID = "component_id" +} + public class DeepLink { - init(dictionary: [String: Any]) { - + let courseID: String? + let screenName: String? + let pathID: String? + let topicID: String? + let threadID: String? + let commentID: String? + let componentID: String? + var type: DeepLinkType + + init(dictionary: [AnyHashable: Any]) { + courseID = dictionary[DeepLinkKeys.courseID] as? String + screenName = dictionary[DeepLinkKeys.screenName] as? String + pathID = dictionary[DeepLinkKeys.pathID] as? String + topicID = dictionary[DeepLinkKeys.topicID] as? String + threadID = dictionary[DeepLinkKeys.threadID] as? String + commentID = dictionary[DeepLinkKeys.commentID] as? String + componentID = dictionary[DeepLinkKeys.componentID] as? String + type = DeepLinkType(rawValue: screenName ?? DeepLinkType.none.rawValue) ?? .none } } diff --git a/OpenEdX/Managers/DeepLinkManager/Link/PushLink.swift b/OpenEdX/Managers/DeepLinkManager/Link/PushLink.swift index 262bda304..64d792b1a 100644 --- a/OpenEdX/Managers/DeepLinkManager/Link/PushLink.swift +++ b/OpenEdX/Managers/DeepLinkManager/Link/PushLink.swift @@ -6,8 +6,9 @@ // import Foundation +import Core -enum DataKeys: String { +enum DataKeys: String, RawStringExtractable { case title case body case aps @@ -19,11 +20,11 @@ public class PushLink: DeepLink { let title: String? let body: String? - override init(dictionary: [String: Any]) { - let aps = dictionary[DataKeys.aps.rawValue] as? [String: Any] - let alert = aps?[DataKeys.alert.rawValue] as? [String: Any] - title = alert?[DataKeys.title.rawValue] as? String - body = alert?[DataKeys.body.rawValue] as? String + override init(dictionary: [AnyHashable: Any]) { + let aps = dictionary[DataKeys.aps] as? [String: Any] + let alert = aps?[DataKeys.alert] as? [String: Any] + title = alert?[DataKeys.title] as? String + body = alert?[DataKeys.body] as? String super.init(dictionary: dictionary) } diff --git a/OpenEdX/Managers/DeepLinkManager/Services/BranchService.swift b/OpenEdX/Managers/DeepLinkManager/Services/BranchService.swift index 07a402a60..56993ea23 100644 --- a/OpenEdX/Managers/DeepLinkManager/Services/BranchService.swift +++ b/OpenEdX/Managers/DeepLinkManager/Services/BranchService.swift @@ -7,11 +7,26 @@ import Foundation import UIKit +import Core +import BranchSDK class BranchService: DeepLinkService { // configure service - func configureWith(launchOptions: [UIApplication.LaunchOptionsKey: Any]?) { + func configureWith( + manager: DeepLinkManager, + config: ConfigProtocol, + launchOptions: [UIApplication.LaunchOptionsKey: Any]? + ) { + guard let key = config.branch.key, config.branch.enabled else { return } + Branch.setBranchKey(key) + if Branch.branchKey() != nil { + Branch.getInstance().initSession(launchOptions: launchOptions) { params, error in + guard let params = params, error == nil else { return } + + manager.processDeepLink(with: params) + } + } } // handle url and call DeepLinkanager.processDeepLink() with params @@ -20,6 +35,6 @@ class BranchService: DeepLinkService { open url: URL, options: [UIApplication.OpenURLOptionsKey: Any] ) -> Bool { - false + return Branch.getInstance().application(app, open: url, options: options) } } diff --git a/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift b/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift index 130818dd5..be832bcbb 100644 --- a/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift +++ b/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift @@ -22,7 +22,7 @@ protocol PushNotificationsListener { extension PushNotificationsListener { func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) { - guard let dictionary = userInfo as? [String: Any], + guard let dictionary = userInfo as? [String: AnyHashable], shouldListenNotification(userinfo: userInfo), let deepLinkManager = Container.shared.resolve(DeepLinkManager.self) else { return } diff --git a/OpenEdX/OpenEdX.entitlements b/OpenEdX/OpenEdX.entitlements index 80b5221de..08f1694af 100644 --- a/OpenEdX/OpenEdX.entitlements +++ b/OpenEdX/OpenEdX.entitlements @@ -8,5 +8,12 @@ Default + com.apple.developer.associated-domains + + applinks:edx.app.link + applinks:edx-alternate.app.link + applinks:edx.test-app.link + applinks:edx-alternate.test-app.link + diff --git a/Podfile.lock b/Podfile.lock index 20a87d7f1..e96b3c125 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -182,4 +182,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 544edab2f9ecc4ac18973fb8865f1d0613ec8a28 -COCOAPODS: 1.15.0 +COCOAPODS: 1.15.2 diff --git a/config_script/process_config.py b/config_script/process_config.py index 36340bfc3..3c497a6b8 100644 --- a/config_script/process_config.py +++ b/config_script/process_config.py @@ -22,6 +22,9 @@ def get_bundle_identifier(self): def get_info_plist_path(self): return os.getenv('INFOPLIST_PATH') + def get_entitlements_plist_path(self): + return os.getenv('CODE_SIGN_ENTITLEMENTS') + def get_wrapper_name(self): return os.getenv('WRAPPER_NAME') @@ -40,6 +43,15 @@ def get_app_info_plist_path(self): else: return None + def get_entitlements_path(self): + built_products_path = self.get_built_products_path() + entitlements_plist_path = self.get_entitlements_path() + + if built_products_path and entitlements_plist_path: + return os.path.join(built_products_path, entitlements_plist_path) + else: + return None + def get_firebase_info_plist_path(self): built_products_path = self.get_built_products_path() wrapper_name = self.get_wrapper_name() @@ -133,18 +145,32 @@ def __init__(self, plist_manager): def get_environment_variable(self, variable): return os.getenv(variable) - def add_url_scheme(self, scheme, plist): + def add_url_scheme(self, scheme, plist, addBundleURL): body = { 'CFBundleTypeRole': 'Editor', 'CFBundleURLSchemes': scheme } + + if addBundleURL: + bundle_identifier = self.plist_manager.get_bundle_identifier() + body['CFBundleURLName'] = bundle_identifier + existing = plist.get('CFBundleURLTypes', []) found = any(scheme in entry.get('CFBundleURLSchemes', []) for entry in existing) if not found: existing.append(body) plist['CFBundleURLTypes'] = existing - + + def add_custom_array(self, key, array, plist): + existing = plist.get(key, []) + + for element in array: + if element not in existing: + existing.append(element) + + plist[key] = existing + def add_application_query_schemes(self, schemes, plist): existing = plist.get('LSApplicationQueriesSchemes', []) for scheme in schemes: @@ -177,7 +203,37 @@ def add_firebase_config(self, config, firebase_info_plist_path): self.plist_manager.write_to_plist_file(plist, self.plist_manager.get_firebase_info_plist_path()) else: print("Firebase config is empty. Skipping") - + + def add_branch_config(self, config, plist, entitlements): + branch = config.get('BRANCH', {}) + enabled = branch.get('ENABLED') + uriScheme = branch.get('URI_SCHEME') + prefix = branch.get('DEEPLINK_PREFIX') + + if not prefix: + prefix = "edx" + + if enabled: + if uriScheme: + scheme = [uriScheme] + else: + bundle_identifier = self.plist_manager.get_bundle_identifier() + scheme = [bundle_identifier] + + self.add_custom_array("branch_universal_link_domains", [ + prefix+".app.link", + prefix+"-alternate.app.link", + prefix+".test-app.link", + prefix+"-alternate.test-app.link" + ], plist) + self.add_custom_array("com.apple.developer.associated-domains", [ + "applinks:"+prefix+".app.link", + "applinks:"+prefix+"-alternate.app.link", + "applinks:"+prefix+".test-app.link", + "applinks:"+prefix+"-alternate.test-app.link" + ], entitlements) + self.add_url_scheme(scheme, plist, True) + def add_facebook_config(self, config, plist): facebook = config.get('FACEBOOK', {}) key = facebook.get('FACEBOOK_APP_ID') @@ -188,7 +244,7 @@ def add_facebook_config(self, config, plist): plist["FacebookClientToken"] = client_token plist["FacebookDisplayName"] = self.plist_manager.get_product_name() scheme = ["fb" + key] - self.add_url_scheme(scheme, plist) + self.add_url_scheme(scheme, plist, False) def add_google_config(self, config, plist): google = config.get('GOOGLE', {}) @@ -198,7 +254,7 @@ def add_google_config(self, config, plist): if key and client_id: plist["GIDClientID"] = client_id scheme = ['.'.join(reversed(key.split('.')))] - self.add_url_scheme(scheme, plist) + self.add_url_scheme(scheme, plist, False) def add_microsoft_config(self, config, plist): microsoft = config.get('MICROSOFT', {}) @@ -207,7 +263,7 @@ def add_microsoft_config(self, config, plist): if key: bundle_identifier = self.plist_manager.get_bundle_identifier() scheme = ["msauth." + bundle_identifier] - self.add_url_scheme(scheme, plist) + self.add_url_scheme(scheme, plist, False) self.add_application_query_schemes(["msauthv2", "msauthv3"], plist) def update_info_plist(self, plist_data, plist_path): @@ -256,6 +312,9 @@ def get_current_config(configuration, scheme_mappings): return None def process_plist_files(configuration_manager, plist_manager, config): + entitlements_path = plist_manager.get_entitlements_plist_path() + entitlements_content = plist_manager.get_info_plist_contents(entitlements_path) + firebase_info_plist_path = plist_manager.get_firebase_config_path() info_plist_path = plist_manager.get_app_info_plist_path() info_plist_content = plist_manager.get_info_plist_contents(info_plist_path) @@ -264,8 +323,10 @@ def process_plist_files(configuration_manager, plist_manager, config): configuration_manager.add_facebook_config(config, info_plist_content) configuration_manager.add_google_config(config, info_plist_content) configuration_manager.add_microsoft_config(config, info_plist_content) + configuration_manager.add_branch_config(config, info_plist_content, entitlements_content) configuration_manager.update_info_plist(info_plist_content, info_plist_path) + configuration_manager.update_info_plist(entitlements_content, entitlements_path) bundle_config_path = plist_manager.get_bundle_config_path() config_plist = plist_manager.yaml_to_plist() From fb220732bf3dd7360568524732c9579c864b94b6 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Mon, 26 Feb 2024 11:01:07 +0100 Subject: [PATCH 031/136] chore: auto-generated mocks --- .../AuthorizationMock.generated.swift | 246 ------------------ Course/CourseTests/CourseMock.generated.swift | 246 ------------------ .../DashboardMock.generated.swift | 246 ------------------ .../DiscoveryMock.generated.swift | 246 ------------------ .../DiscussionMock.generated.swift | 246 ------------------ 5 files changed, 1230 deletions(-) diff --git a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift index 40d06692c..d1f8d0a71 100644 --- a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift +++ b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift @@ -2716,249 +2716,3 @@ open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock } } -// MARK: - WebviewCookiesUpdateProtocol - -open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { - public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { - SwiftyMockyTestObserver.setup() - self.sequencingPolicy = sequencingPolicy - self.stubbingPolicy = stubbingPolicy - self.file = file - self.line = line - } - - var matcher: Matcher = Matcher.default - var stubbingPolicy: StubbingPolicy = .wrap - var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst - - private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) - private var invocations: [MethodType] = [] - private var methodReturnValues: [Given] = [] - private var methodPerformValues: [Perform] = [] - private var file: StaticString? - private var line: UInt? - - public typealias PropertyStub = Given - public typealias MethodStub = Given - public typealias SubscriptStub = Given - - /// Convenience method - call setupMock() to extend debug information when failure occurs - public func setupMock(file: StaticString = #file, line: UInt = #line) { - self.file = file - self.line = line - } - - /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals - public func resetMock(_ scopes: MockScope...) { - let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes - if scopes.contains(.invocation) { invocations = [] } - if scopes.contains(.given) { methodReturnValues = [] } - if scopes.contains(.perform) { methodPerformValues = [] } - } - - public var authInteractor: AuthInteractorProtocol { - get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } - } - private var __p_authInteractor: (AuthInteractorProtocol)? - - public var cookiesReady: Bool { - get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } - set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } - } - private var __p_cookiesReady: (Bool)? - - public var updatingCookies: Bool { - get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } - set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } - } - private var __p_updatingCookies: (Bool)? - - public var errorMessage: String? { - get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } - set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } - } - private var __p_errorMessage: (String)? - - - - - - open func updateCookies(force: Bool, retryCount: Int) { - addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) - let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void - perform?(`force`, `retryCount`) - } - - - fileprivate enum MethodType { - case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) - case p_authInteractor_get - case p_cookiesReady_get - case p_cookiesReady_set(Parameter) - case p_updatingCookies_get - case p_updatingCookies_set(Parameter) - case p_errorMessage_get - case p_errorMessage_set(Parameter) - - static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { - switch (lhs, rhs) { - case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) - return Matcher.ComparisonResult(results) - case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match - case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match - case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match - case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match - case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - default: return .none - } - } - - func intValue() -> Int { - switch self { - case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue - case .p_authInteractor_get: return 0 - case .p_cookiesReady_get: return 0 - case .p_cookiesReady_set(let newValue): return newValue.intValue - case .p_updatingCookies_get: return 0 - case .p_updatingCookies_set(let newValue): return newValue.intValue - case .p_errorMessage_get: return 0 - case .p_errorMessage_set(let newValue): return newValue.intValue - } - } - func assertionName() -> String { - switch self { - case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" - case .p_authInteractor_get: return "[get] .authInteractor" - case .p_cookiesReady_get: return "[get] .cookiesReady" - case .p_cookiesReady_set: return "[set] .cookiesReady" - case .p_updatingCookies_get: return "[get] .updatingCookies" - case .p_updatingCookies_set: return "[set] .updatingCookies" - case .p_errorMessage_get: return "[get] .errorMessage" - case .p_errorMessage_set: return "[set] .errorMessage" - } - } - } - - open class Given: StubbedMethod { - fileprivate var method: MethodType - - private init(method: MethodType, products: [StubProduct]) { - self.method = method - super.init(products) - } - - public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { - return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { - return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - - } - - public struct Verify { - fileprivate var method: MethodType - - public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} - public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } - public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } - public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } - public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } - public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } - public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } - public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } - } - - public struct Perform { - fileprivate var method: MethodType - var performs: Any - - public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { - return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) - } - } - - public func given(_ method: Given) { - methodReturnValues.append(method) - } - - public func perform(_ method: Perform) { - methodPerformValues.append(method) - methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } - } - - public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { - let fullMatches = matchingCalls(method, file: file, line: line) - let success = count.matches(fullMatches) - let assertionName = method.method.assertionName() - let feedback: String = { - guard !success else { return "" } - return Utils.closestCallsMessage( - for: self.invocations.map { invocation in - matcher.set(file: file, line: line) - defer { matcher.clearFileAndLine() } - return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) - }, - name: assertionName - ) - }() - MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) - } - - private func addInvocation(_ call: MethodType) { - self.queue.sync { invocations.append(call) } - } - private func methodReturnValue(_ method: MethodType) throws -> StubProduct { - matcher.set(file: self.file, line: self.line) - defer { matcher.clearFileAndLine() } - let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) - let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) - guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } - return product - } - private func methodPerformValue(_ method: MethodType) -> Any? { - matcher.set(file: self.file, line: self.line) - defer { matcher.clearFileAndLine() } - let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } - return matched?.performs - } - private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { - matcher.set(file: file ?? self.file, line: line ?? self.line) - defer { matcher.clearFileAndLine() } - return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } - } - private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { - return matchingCalls(method.method, file: file, line: line).count - } - private func givenGetterValue(_ method: MethodType, _ message: String) -> T { - do { - return try methodReturnValue(method).casted() - } catch { - onFatalFailure(message) - Failure(message) - } - } - private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { - do { - return try methodReturnValue(method).casted() - } catch { - return nil - } - } - private func onFatalFailure(_ message: String) { - guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully - SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) - } -} - diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index 17ce81c80..dabfab196 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -2906,249 +2906,3 @@ open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock } } -// MARK: - WebviewCookiesUpdateProtocol - -open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { - public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { - SwiftyMockyTestObserver.setup() - self.sequencingPolicy = sequencingPolicy - self.stubbingPolicy = stubbingPolicy - self.file = file - self.line = line - } - - var matcher: Matcher = Matcher.default - var stubbingPolicy: StubbingPolicy = .wrap - var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst - - private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) - private var invocations: [MethodType] = [] - private var methodReturnValues: [Given] = [] - private var methodPerformValues: [Perform] = [] - private var file: StaticString? - private var line: UInt? - - public typealias PropertyStub = Given - public typealias MethodStub = Given - public typealias SubscriptStub = Given - - /// Convenience method - call setupMock() to extend debug information when failure occurs - public func setupMock(file: StaticString = #file, line: UInt = #line) { - self.file = file - self.line = line - } - - /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals - public func resetMock(_ scopes: MockScope...) { - let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes - if scopes.contains(.invocation) { invocations = [] } - if scopes.contains(.given) { methodReturnValues = [] } - if scopes.contains(.perform) { methodPerformValues = [] } - } - - public var authInteractor: AuthInteractorProtocol { - get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } - } - private var __p_authInteractor: (AuthInteractorProtocol)? - - public var cookiesReady: Bool { - get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } - set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } - } - private var __p_cookiesReady: (Bool)? - - public var updatingCookies: Bool { - get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } - set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } - } - private var __p_updatingCookies: (Bool)? - - public var errorMessage: String? { - get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } - set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } - } - private var __p_errorMessage: (String)? - - - - - - open func updateCookies(force: Bool, retryCount: Int) { - addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) - let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void - perform?(`force`, `retryCount`) - } - - - fileprivate enum MethodType { - case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) - case p_authInteractor_get - case p_cookiesReady_get - case p_cookiesReady_set(Parameter) - case p_updatingCookies_get - case p_updatingCookies_set(Parameter) - case p_errorMessage_get - case p_errorMessage_set(Parameter) - - static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { - switch (lhs, rhs) { - case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) - return Matcher.ComparisonResult(results) - case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match - case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match - case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match - case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match - case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - default: return .none - } - } - - func intValue() -> Int { - switch self { - case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue - case .p_authInteractor_get: return 0 - case .p_cookiesReady_get: return 0 - case .p_cookiesReady_set(let newValue): return newValue.intValue - case .p_updatingCookies_get: return 0 - case .p_updatingCookies_set(let newValue): return newValue.intValue - case .p_errorMessage_get: return 0 - case .p_errorMessage_set(let newValue): return newValue.intValue - } - } - func assertionName() -> String { - switch self { - case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" - case .p_authInteractor_get: return "[get] .authInteractor" - case .p_cookiesReady_get: return "[get] .cookiesReady" - case .p_cookiesReady_set: return "[set] .cookiesReady" - case .p_updatingCookies_get: return "[get] .updatingCookies" - case .p_updatingCookies_set: return "[set] .updatingCookies" - case .p_errorMessage_get: return "[get] .errorMessage" - case .p_errorMessage_set: return "[set] .errorMessage" - } - } - } - - open class Given: StubbedMethod { - fileprivate var method: MethodType - - private init(method: MethodType, products: [StubProduct]) { - self.method = method - super.init(products) - } - - public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { - return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { - return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - - } - - public struct Verify { - fileprivate var method: MethodType - - public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} - public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } - public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } - public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } - public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } - public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } - public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } - public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } - } - - public struct Perform { - fileprivate var method: MethodType - var performs: Any - - public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { - return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) - } - } - - public func given(_ method: Given) { - methodReturnValues.append(method) - } - - public func perform(_ method: Perform) { - methodPerformValues.append(method) - methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } - } - - public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { - let fullMatches = matchingCalls(method, file: file, line: line) - let success = count.matches(fullMatches) - let assertionName = method.method.assertionName() - let feedback: String = { - guard !success else { return "" } - return Utils.closestCallsMessage( - for: self.invocations.map { invocation in - matcher.set(file: file, line: line) - defer { matcher.clearFileAndLine() } - return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) - }, - name: assertionName - ) - }() - MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) - } - - private func addInvocation(_ call: MethodType) { - self.queue.sync { invocations.append(call) } - } - private func methodReturnValue(_ method: MethodType) throws -> StubProduct { - matcher.set(file: self.file, line: self.line) - defer { matcher.clearFileAndLine() } - let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) - let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) - guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } - return product - } - private func methodPerformValue(_ method: MethodType) -> Any? { - matcher.set(file: self.file, line: self.line) - defer { matcher.clearFileAndLine() } - let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } - return matched?.performs - } - private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { - matcher.set(file: file ?? self.file, line: line ?? self.line) - defer { matcher.clearFileAndLine() } - return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } - } - private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { - return matchingCalls(method.method, file: file, line: line).count - } - private func givenGetterValue(_ method: MethodType, _ message: String) -> T { - do { - return try methodReturnValue(method).casted() - } catch { - onFatalFailure(message) - Failure(message) - } - } - private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { - do { - return try methodReturnValue(method).casted() - } catch { - return nil - } - } - private func onFatalFailure(_ message: String) { - guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully - SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) - } -} - diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index a4d3d6380..b8a417000 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -2384,249 +2384,3 @@ open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock } } -// MARK: - WebviewCookiesUpdateProtocol - -open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { - public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { - SwiftyMockyTestObserver.setup() - self.sequencingPolicy = sequencingPolicy - self.stubbingPolicy = stubbingPolicy - self.file = file - self.line = line - } - - var matcher: Matcher = Matcher.default - var stubbingPolicy: StubbingPolicy = .wrap - var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst - - private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) - private var invocations: [MethodType] = [] - private var methodReturnValues: [Given] = [] - private var methodPerformValues: [Perform] = [] - private var file: StaticString? - private var line: UInt? - - public typealias PropertyStub = Given - public typealias MethodStub = Given - public typealias SubscriptStub = Given - - /// Convenience method - call setupMock() to extend debug information when failure occurs - public func setupMock(file: StaticString = #file, line: UInt = #line) { - self.file = file - self.line = line - } - - /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals - public func resetMock(_ scopes: MockScope...) { - let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes - if scopes.contains(.invocation) { invocations = [] } - if scopes.contains(.given) { methodReturnValues = [] } - if scopes.contains(.perform) { methodPerformValues = [] } - } - - public var authInteractor: AuthInteractorProtocol { - get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } - } - private var __p_authInteractor: (AuthInteractorProtocol)? - - public var cookiesReady: Bool { - get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } - set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } - } - private var __p_cookiesReady: (Bool)? - - public var updatingCookies: Bool { - get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } - set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } - } - private var __p_updatingCookies: (Bool)? - - public var errorMessage: String? { - get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } - set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } - } - private var __p_errorMessage: (String)? - - - - - - open func updateCookies(force: Bool, retryCount: Int) { - addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) - let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void - perform?(`force`, `retryCount`) - } - - - fileprivate enum MethodType { - case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) - case p_authInteractor_get - case p_cookiesReady_get - case p_cookiesReady_set(Parameter) - case p_updatingCookies_get - case p_updatingCookies_set(Parameter) - case p_errorMessage_get - case p_errorMessage_set(Parameter) - - static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { - switch (lhs, rhs) { - case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) - return Matcher.ComparisonResult(results) - case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match - case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match - case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match - case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match - case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - default: return .none - } - } - - func intValue() -> Int { - switch self { - case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue - case .p_authInteractor_get: return 0 - case .p_cookiesReady_get: return 0 - case .p_cookiesReady_set(let newValue): return newValue.intValue - case .p_updatingCookies_get: return 0 - case .p_updatingCookies_set(let newValue): return newValue.intValue - case .p_errorMessage_get: return 0 - case .p_errorMessage_set(let newValue): return newValue.intValue - } - } - func assertionName() -> String { - switch self { - case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" - case .p_authInteractor_get: return "[get] .authInteractor" - case .p_cookiesReady_get: return "[get] .cookiesReady" - case .p_cookiesReady_set: return "[set] .cookiesReady" - case .p_updatingCookies_get: return "[get] .updatingCookies" - case .p_updatingCookies_set: return "[set] .updatingCookies" - case .p_errorMessage_get: return "[get] .errorMessage" - case .p_errorMessage_set: return "[set] .errorMessage" - } - } - } - - open class Given: StubbedMethod { - fileprivate var method: MethodType - - private init(method: MethodType, products: [StubProduct]) { - self.method = method - super.init(products) - } - - public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { - return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { - return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - - } - - public struct Verify { - fileprivate var method: MethodType - - public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} - public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } - public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } - public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } - public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } - public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } - public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } - public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } - } - - public struct Perform { - fileprivate var method: MethodType - var performs: Any - - public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { - return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) - } - } - - public func given(_ method: Given) { - methodReturnValues.append(method) - } - - public func perform(_ method: Perform) { - methodPerformValues.append(method) - methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } - } - - public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { - let fullMatches = matchingCalls(method, file: file, line: line) - let success = count.matches(fullMatches) - let assertionName = method.method.assertionName() - let feedback: String = { - guard !success else { return "" } - return Utils.closestCallsMessage( - for: self.invocations.map { invocation in - matcher.set(file: file, line: line) - defer { matcher.clearFileAndLine() } - return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) - }, - name: assertionName - ) - }() - MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) - } - - private func addInvocation(_ call: MethodType) { - self.queue.sync { invocations.append(call) } - } - private func methodReturnValue(_ method: MethodType) throws -> StubProduct { - matcher.set(file: self.file, line: self.line) - defer { matcher.clearFileAndLine() } - let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) - let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) - guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } - return product - } - private func methodPerformValue(_ method: MethodType) -> Any? { - matcher.set(file: self.file, line: self.line) - defer { matcher.clearFileAndLine() } - let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } - return matched?.performs - } - private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { - matcher.set(file: file ?? self.file, line: line ?? self.line) - defer { matcher.clearFileAndLine() } - return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } - } - private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { - return matchingCalls(method.method, file: file, line: line).count - } - private func givenGetterValue(_ method: MethodType, _ message: String) -> T { - do { - return try methodReturnValue(method).casted() - } catch { - onFatalFailure(message) - Failure(message) - } - } - private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { - do { - return try methodReturnValue(method).casted() - } catch { - return nil - } - } - private func onFatalFailure(_ message: String) { - guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully - SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) - } -} - diff --git a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index 8014ac0df..ef22e064f 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -2641,249 +2641,3 @@ open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock } } -// MARK: - WebviewCookiesUpdateProtocol - -open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { - public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { - SwiftyMockyTestObserver.setup() - self.sequencingPolicy = sequencingPolicy - self.stubbingPolicy = stubbingPolicy - self.file = file - self.line = line - } - - var matcher: Matcher = Matcher.default - var stubbingPolicy: StubbingPolicy = .wrap - var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst - - private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) - private var invocations: [MethodType] = [] - private var methodReturnValues: [Given] = [] - private var methodPerformValues: [Perform] = [] - private var file: StaticString? - private var line: UInt? - - public typealias PropertyStub = Given - public typealias MethodStub = Given - public typealias SubscriptStub = Given - - /// Convenience method - call setupMock() to extend debug information when failure occurs - public func setupMock(file: StaticString = #file, line: UInt = #line) { - self.file = file - self.line = line - } - - /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals - public func resetMock(_ scopes: MockScope...) { - let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes - if scopes.contains(.invocation) { invocations = [] } - if scopes.contains(.given) { methodReturnValues = [] } - if scopes.contains(.perform) { methodPerformValues = [] } - } - - public var authInteractor: AuthInteractorProtocol { - get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } - } - private var __p_authInteractor: (AuthInteractorProtocol)? - - public var cookiesReady: Bool { - get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } - set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } - } - private var __p_cookiesReady: (Bool)? - - public var updatingCookies: Bool { - get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } - set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } - } - private var __p_updatingCookies: (Bool)? - - public var errorMessage: String? { - get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } - set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } - } - private var __p_errorMessage: (String)? - - - - - - open func updateCookies(force: Bool, retryCount: Int) { - addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) - let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void - perform?(`force`, `retryCount`) - } - - - fileprivate enum MethodType { - case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) - case p_authInteractor_get - case p_cookiesReady_get - case p_cookiesReady_set(Parameter) - case p_updatingCookies_get - case p_updatingCookies_set(Parameter) - case p_errorMessage_get - case p_errorMessage_set(Parameter) - - static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { - switch (lhs, rhs) { - case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) - return Matcher.ComparisonResult(results) - case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match - case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match - case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match - case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match - case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - default: return .none - } - } - - func intValue() -> Int { - switch self { - case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue - case .p_authInteractor_get: return 0 - case .p_cookiesReady_get: return 0 - case .p_cookiesReady_set(let newValue): return newValue.intValue - case .p_updatingCookies_get: return 0 - case .p_updatingCookies_set(let newValue): return newValue.intValue - case .p_errorMessage_get: return 0 - case .p_errorMessage_set(let newValue): return newValue.intValue - } - } - func assertionName() -> String { - switch self { - case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" - case .p_authInteractor_get: return "[get] .authInteractor" - case .p_cookiesReady_get: return "[get] .cookiesReady" - case .p_cookiesReady_set: return "[set] .cookiesReady" - case .p_updatingCookies_get: return "[get] .updatingCookies" - case .p_updatingCookies_set: return "[set] .updatingCookies" - case .p_errorMessage_get: return "[get] .errorMessage" - case .p_errorMessage_set: return "[set] .errorMessage" - } - } - } - - open class Given: StubbedMethod { - fileprivate var method: MethodType - - private init(method: MethodType, products: [StubProduct]) { - self.method = method - super.init(products) - } - - public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { - return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { - return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - - } - - public struct Verify { - fileprivate var method: MethodType - - public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} - public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } - public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } - public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } - public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } - public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } - public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } - public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } - } - - public struct Perform { - fileprivate var method: MethodType - var performs: Any - - public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { - return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) - } - } - - public func given(_ method: Given) { - methodReturnValues.append(method) - } - - public func perform(_ method: Perform) { - methodPerformValues.append(method) - methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } - } - - public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { - let fullMatches = matchingCalls(method, file: file, line: line) - let success = count.matches(fullMatches) - let assertionName = method.method.assertionName() - let feedback: String = { - guard !success else { return "" } - return Utils.closestCallsMessage( - for: self.invocations.map { invocation in - matcher.set(file: file, line: line) - defer { matcher.clearFileAndLine() } - return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) - }, - name: assertionName - ) - }() - MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) - } - - private func addInvocation(_ call: MethodType) { - self.queue.sync { invocations.append(call) } - } - private func methodReturnValue(_ method: MethodType) throws -> StubProduct { - matcher.set(file: self.file, line: self.line) - defer { matcher.clearFileAndLine() } - let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) - let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) - guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } - return product - } - private func methodPerformValue(_ method: MethodType) -> Any? { - matcher.set(file: self.file, line: self.line) - defer { matcher.clearFileAndLine() } - let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } - return matched?.performs - } - private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { - matcher.set(file: file ?? self.file, line: line ?? self.line) - defer { matcher.clearFileAndLine() } - return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } - } - private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { - return matchingCalls(method.method, file: file, line: line).count - } - private func givenGetterValue(_ method: MethodType, _ message: String) -> T { - do { - return try methodReturnValue(method).casted() - } catch { - onFatalFailure(message) - Failure(message) - } - } - private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { - do { - return try methodReturnValue(method).casted() - } catch { - return nil - } - } - private func onFatalFailure(_ message: String) { - guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully - SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) - } -} - diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index be052b0d4..336d50a14 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -3462,249 +3462,3 @@ open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock } } -// MARK: - WebviewCookiesUpdateProtocol - -open class WebviewCookiesUpdateProtocolMock: WebviewCookiesUpdateProtocol, Mock { - public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { - SwiftyMockyTestObserver.setup() - self.sequencingPolicy = sequencingPolicy - self.stubbingPolicy = stubbingPolicy - self.file = file - self.line = line - } - - var matcher: Matcher = Matcher.default - var stubbingPolicy: StubbingPolicy = .wrap - var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst - - private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) - private var invocations: [MethodType] = [] - private var methodReturnValues: [Given] = [] - private var methodPerformValues: [Perform] = [] - private var file: StaticString? - private var line: UInt? - - public typealias PropertyStub = Given - public typealias MethodStub = Given - public typealias SubscriptStub = Given - - /// Convenience method - call setupMock() to extend debug information when failure occurs - public func setupMock(file: StaticString = #file, line: UInt = #line) { - self.file = file - self.line = line - } - - /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals - public func resetMock(_ scopes: MockScope...) { - let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes - if scopes.contains(.invocation) { invocations = [] } - if scopes.contains(.given) { methodReturnValues = [] } - if scopes.contains(.perform) { methodPerformValues = [] } - } - - public var authInteractor: AuthInteractorProtocol { - get { invocations.append(.p_authInteractor_get); return __p_authInteractor ?? givenGetterValue(.p_authInteractor_get, "WebviewCookiesUpdateProtocolMock - stub value for authInteractor was not defined") } - } - private var __p_authInteractor: (AuthInteractorProtocol)? - - public var cookiesReady: Bool { - get { invocations.append(.p_cookiesReady_get); return __p_cookiesReady ?? givenGetterValue(.p_cookiesReady_get, "WebviewCookiesUpdateProtocolMock - stub value for cookiesReady was not defined") } - set { invocations.append(.p_cookiesReady_set(.value(newValue))); __p_cookiesReady = newValue } - } - private var __p_cookiesReady: (Bool)? - - public var updatingCookies: Bool { - get { invocations.append(.p_updatingCookies_get); return __p_updatingCookies ?? givenGetterValue(.p_updatingCookies_get, "WebviewCookiesUpdateProtocolMock - stub value for updatingCookies was not defined") } - set { invocations.append(.p_updatingCookies_set(.value(newValue))); __p_updatingCookies = newValue } - } - private var __p_updatingCookies: (Bool)? - - public var errorMessage: String? { - get { invocations.append(.p_errorMessage_get); return __p_errorMessage ?? optionalGivenGetterValue(.p_errorMessage_get, "WebviewCookiesUpdateProtocolMock - stub value for errorMessage was not defined") } - set { invocations.append(.p_errorMessage_set(.value(newValue))); __p_errorMessage = newValue } - } - private var __p_errorMessage: (String)? - - - - - - open func updateCookies(force: Bool, retryCount: Int) { - addInvocation(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) - let perform = methodPerformValue(.m_updateCookies__force_forceretryCount_retryCount(Parameter.value(`force`), Parameter.value(`retryCount`))) as? (Bool, Int) -> Void - perform?(`force`, `retryCount`) - } - - - fileprivate enum MethodType { - case m_updateCookies__force_forceretryCount_retryCount(Parameter, Parameter) - case p_authInteractor_get - case p_cookiesReady_get - case p_cookiesReady_set(Parameter) - case p_updatingCookies_get - case p_updatingCookies_set(Parameter) - case p_errorMessage_get - case p_errorMessage_set(Parameter) - - static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { - switch (lhs, rhs) { - case (.m_updateCookies__force_forceretryCount_retryCount(let lhsForce, let lhsRetrycount), .m_updateCookies__force_forceretryCount_retryCount(let rhsForce, let rhsRetrycount)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRetrycount, rhs: rhsRetrycount, with: matcher), lhsRetrycount, rhsRetrycount, "retryCount")) - return Matcher.ComparisonResult(results) - case (.p_authInteractor_get,.p_authInteractor_get): return Matcher.ComparisonResult.match - case (.p_cookiesReady_get,.p_cookiesReady_get): return Matcher.ComparisonResult.match - case (.p_cookiesReady_set(let left),.p_cookiesReady_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_updatingCookies_get,.p_updatingCookies_get): return Matcher.ComparisonResult.match - case (.p_updatingCookies_set(let left),.p_updatingCookies_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_errorMessage_get,.p_errorMessage_get): return Matcher.ComparisonResult.match - case (.p_errorMessage_set(let left),.p_errorMessage_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - default: return .none - } - } - - func intValue() -> Int { - switch self { - case let .m_updateCookies__force_forceretryCount_retryCount(p0, p1): return p0.intValue + p1.intValue - case .p_authInteractor_get: return 0 - case .p_cookiesReady_get: return 0 - case .p_cookiesReady_set(let newValue): return newValue.intValue - case .p_updatingCookies_get: return 0 - case .p_updatingCookies_set(let newValue): return newValue.intValue - case .p_errorMessage_get: return 0 - case .p_errorMessage_set(let newValue): return newValue.intValue - } - } - func assertionName() -> String { - switch self { - case .m_updateCookies__force_forceretryCount_retryCount: return ".updateCookies(force:retryCount:)" - case .p_authInteractor_get: return "[get] .authInteractor" - case .p_cookiesReady_get: return "[get] .cookiesReady" - case .p_cookiesReady_set: return "[set] .cookiesReady" - case .p_updatingCookies_get: return "[get] .updatingCookies" - case .p_updatingCookies_set: return "[set] .updatingCookies" - case .p_errorMessage_get: return "[get] .errorMessage" - case .p_errorMessage_set: return "[set] .errorMessage" - } - } - } - - open class Given: StubbedMethod { - fileprivate var method: MethodType - - private init(method: MethodType, products: [StubProduct]) { - self.method = method - super.init(products) - } - - public static func authInteractor(getter defaultValue: AuthInteractorProtocol...) -> PropertyStub { - return Given(method: .p_authInteractor_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func cookiesReady(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_cookiesReady_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func updatingCookies(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_updatingCookies_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func errorMessage(getter defaultValue: String?...) -> PropertyStub { - return Given(method: .p_errorMessage_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - - } - - public struct Verify { - fileprivate var method: MethodType - - public static func updateCookies(force: Parameter, retryCount: Parameter) -> Verify { return Verify(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`))} - public static var authInteractor: Verify { return Verify(method: .p_authInteractor_get) } - public static var cookiesReady: Verify { return Verify(method: .p_cookiesReady_get) } - public static func cookiesReady(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesReady_set(newValue)) } - public static var updatingCookies: Verify { return Verify(method: .p_updatingCookies_get) } - public static func updatingCookies(set newValue: Parameter) -> Verify { return Verify(method: .p_updatingCookies_set(newValue)) } - public static var errorMessage: Verify { return Verify(method: .p_errorMessage_get) } - public static func errorMessage(set newValue: Parameter) -> Verify { return Verify(method: .p_errorMessage_set(newValue)) } - } - - public struct Perform { - fileprivate var method: MethodType - var performs: Any - - public static func updateCookies(force: Parameter, retryCount: Parameter, perform: @escaping (Bool, Int) -> Void) -> Perform { - return Perform(method: .m_updateCookies__force_forceretryCount_retryCount(`force`, `retryCount`), performs: perform) - } - } - - public func given(_ method: Given) { - methodReturnValues.append(method) - } - - public func perform(_ method: Perform) { - methodPerformValues.append(method) - methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } - } - - public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { - let fullMatches = matchingCalls(method, file: file, line: line) - let success = count.matches(fullMatches) - let assertionName = method.method.assertionName() - let feedback: String = { - guard !success else { return "" } - return Utils.closestCallsMessage( - for: self.invocations.map { invocation in - matcher.set(file: file, line: line) - defer { matcher.clearFileAndLine() } - return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) - }, - name: assertionName - ) - }() - MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) - } - - private func addInvocation(_ call: MethodType) { - self.queue.sync { invocations.append(call) } - } - private func methodReturnValue(_ method: MethodType) throws -> StubProduct { - matcher.set(file: self.file, line: self.line) - defer { matcher.clearFileAndLine() } - let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) - let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) - guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } - return product - } - private func methodPerformValue(_ method: MethodType) -> Any? { - matcher.set(file: self.file, line: self.line) - defer { matcher.clearFileAndLine() } - let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } - return matched?.performs - } - private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { - matcher.set(file: file ?? self.file, line: line ?? self.line) - defer { matcher.clearFileAndLine() } - return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } - } - private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { - return matchingCalls(method.method, file: file, line: line).count - } - private func givenGetterValue(_ method: MethodType, _ message: String) -> T { - do { - return try methodReturnValue(method).casted() - } catch { - onFatalFailure(message) - Failure(message) - } - } - private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { - do { - return try methodReturnValue(method).casted() - } catch { - return nil - } - } - private func onFatalFailure(_ message: String) { - guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully - SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) - } -} - From 97115125777521f6f13a6f8a406f12332568cbbe Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Wed, 28 Feb 2024 15:26:42 +0500 Subject: [PATCH 032/136] fix: remove entitlement files editing for associated domains from config script --- OpenEdX.xcodeproj/project.pbxproj | 6 ------ config_script/process_config.py | 26 ++------------------------ 2 files changed, 2 insertions(+), 30 deletions(-) diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 22d5b9374..a7084fabf 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -683,7 +683,6 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -772,7 +771,6 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -867,7 +865,6 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -956,7 +953,6 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -1105,7 +1101,6 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -1140,7 +1135,6 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ALLOW_ENTITLEMENTS_MODIFICATION = YES; CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; diff --git a/config_script/process_config.py b/config_script/process_config.py index 3c497a6b8..202f78d99 100644 --- a/config_script/process_config.py +++ b/config_script/process_config.py @@ -21,9 +21,6 @@ def get_bundle_identifier(self): def get_info_plist_path(self): return os.getenv('INFOPLIST_PATH') - - def get_entitlements_plist_path(self): - return os.getenv('CODE_SIGN_ENTITLEMENTS') def get_wrapper_name(self): return os.getenv('WRAPPER_NAME') @@ -42,15 +39,6 @@ def get_app_info_plist_path(self): return os.path.join(built_products_path, info_plist_path) else: return None - - def get_entitlements_path(self): - built_products_path = self.get_built_products_path() - entitlements_plist_path = self.get_entitlements_path() - - if built_products_path and entitlements_plist_path: - return os.path.join(built_products_path, entitlements_plist_path) - else: - return None def get_firebase_info_plist_path(self): built_products_path = self.get_built_products_path() @@ -204,7 +192,7 @@ def add_firebase_config(self, config, firebase_info_plist_path): else: print("Firebase config is empty. Skipping") - def add_branch_config(self, config, plist, entitlements): + def add_branch_config(self, config, plist): branch = config.get('BRANCH', {}) enabled = branch.get('ENABLED') uriScheme = branch.get('URI_SCHEME') @@ -226,12 +214,6 @@ def add_branch_config(self, config, plist, entitlements): prefix+".test-app.link", prefix+"-alternate.test-app.link" ], plist) - self.add_custom_array("com.apple.developer.associated-domains", [ - "applinks:"+prefix+".app.link", - "applinks:"+prefix+"-alternate.app.link", - "applinks:"+prefix+".test-app.link", - "applinks:"+prefix+"-alternate.test-app.link" - ], entitlements) self.add_url_scheme(scheme, plist, True) def add_facebook_config(self, config, plist): @@ -312,9 +294,6 @@ def get_current_config(configuration, scheme_mappings): return None def process_plist_files(configuration_manager, plist_manager, config): - entitlements_path = plist_manager.get_entitlements_plist_path() - entitlements_content = plist_manager.get_info_plist_contents(entitlements_path) - firebase_info_plist_path = plist_manager.get_firebase_config_path() info_plist_path = plist_manager.get_app_info_plist_path() info_plist_content = plist_manager.get_info_plist_contents(info_plist_path) @@ -323,10 +302,9 @@ def process_plist_files(configuration_manager, plist_manager, config): configuration_manager.add_facebook_config(config, info_plist_content) configuration_manager.add_google_config(config, info_plist_content) configuration_manager.add_microsoft_config(config, info_plist_content) - configuration_manager.add_branch_config(config, info_plist_content, entitlements_content) + configuration_manager.add_branch_config(config, info_plist_content) configuration_manager.update_info_plist(info_plist_content, info_plist_path) - configuration_manager.update_info_plist(entitlements_content, entitlements_path) bundle_config_path = plist_manager.get_bundle_config_path() config_plist = plist_manager.yaml_to_plist() From 2b68d2e7cdaf9ae5ace3b885bf9bea2417e7acd5 Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Wed, 28 Feb 2024 16:08:12 +0500 Subject: [PATCH 033/136] chore: theme improvements and no handouts handling (#294) * chore: theme improvements and no handouts navigation fix * fix: removing extra items * refactor: address review feedback --- .../Presentation/Login/SignInView.swift | 3 +- .../Presentation/Startup/StartupView.swift | 3 +- Core/Core/View/Base/CourseButton.swift | 4 +- Core/Core/View/Base/SnackBarView.swift | 1 + .../View/Base/VideoDownloadQualityView.swift | 3 +- .../Handouts/HandoutsUpdatesDetailView.swift | 7 +++- .../Presentation/Handouts/HandoutsView.swift | 3 +- .../CourseStructureNestedListView.swift | 2 +- .../CourseStructure/CourseStructureView.swift | 2 +- .../DropdownList/CourseUnitDropDownCell.swift | 2 +- Course/Course/SwiftGen/Strings.swift | 2 + Course/Course/en.lproj/Localizable.strings | 1 + Course/Course/uk.lproj/Localizable.strings | 1 + .../DeleteAccount/DeleteAccountView.swift | 2 +- .../Subviews/ProfileSupportInfoView.swift | 2 +- .../Contents.json | 0 .../SnackbarTextColor.colorset}/Contents.json | 14 +++---- .../Colors/Success.colorset/Contents.json | 38 +++++++++++++++++++ Theme/Theme/SwiftGen/ThemeAssets.swift | 7 ++-- Theme/Theme/Theme.swift | 14 +++++-- 20 files changed, 82 insertions(+), 29 deletions(-) rename Theme/Theme/Assets.xcassets/Colors/Snackbar/{SnackbarInfoAlert.colorset => SnackbarInfoColor.colorset}/Contents.json (100%) rename Theme/Theme/Assets.xcassets/Colors/{StyledButton/StyledButtonBackground.colorset => Snackbar/SnackbarTextColor.colorset}/Contents.json (70%) create mode 100644 Theme/Theme/Assets.xcassets/Colors/Success.colorset/Contents.json diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index 13641450c..1b63dfabc 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -50,7 +50,8 @@ public struct SignInView: View { VStack(alignment: .center) { ThemeAssets.appLogoLight.swiftUIImage .resizable() - .frame(maxWidth: 189, maxHeight: 54) + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 189, maxHeight: 89) .padding(.top, isHorizontal ? 20 : 40) .padding(.bottom, isHorizontal ? 10 : 40) .accessibilityIdentifier("logo_image") diff --git a/Authorization/Authorization/Presentation/Startup/StartupView.swift b/Authorization/Authorization/Presentation/Startup/StartupView.swift index 37dbfb2c7..db0eff655 100644 --- a/Authorization/Authorization/Presentation/Startup/StartupView.swift +++ b/Authorization/Authorization/Presentation/Startup/StartupView.swift @@ -28,7 +28,8 @@ public struct StartupView: View { VStack(alignment: .leading) { ThemeAssets.appLogo.swiftUIImage .resizable() - .frame(maxWidth: 189, maxHeight: 54) + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 189, maxHeight: 89) .padding(.top, isHorizontal ? 20 : 40) .padding(.bottom, isHorizontal ? 0 : 20) .padding(.horizontal, isHorizontal ? 10 : 24) diff --git a/Core/Core/View/Base/CourseButton.swift b/Core/Core/View/Base/CourseButton.swift index 428bf2920..9ff48554a 100644 --- a/Core/Core/View/Base/CourseButton.swift +++ b/Core/Core/View/Base/CourseButton.swift @@ -29,7 +29,7 @@ public struct CourseButton: View { if isCompleted { CoreAssets.finished.swiftUIImage .renderingMode(.template) - .foregroundColor(.accentColor) + .foregroundColor(Theme.Colors.accentXColor) } else { image .foregroundColor(Theme.Colors.textPrimary) @@ -41,7 +41,7 @@ public struct CourseButton: View { Spacer() Image(systemName: "chevron.right") .padding(.vertical, 8) - .foregroundColor(.accentColor) + .foregroundColor(Theme.Colors.accentXColor) } .padding(.horizontal, 36) .padding(.vertical, 14) diff --git a/Core/Core/View/Base/SnackBarView.swift b/Core/Core/View/Base/SnackBarView.swift index 78b2af53c..65da1cd72 100644 --- a/Core/Core/View/Base/SnackBarView.swift +++ b/Core/Core/View/Base/SnackBarView.swift @@ -28,6 +28,7 @@ public struct SnackBarView: View { HStack { Text(message) .font(Theme.Fonts.titleSmall) + .foregroundColor(Theme.Colors.snackbarTextColor) Spacer() if let action = action { diff --git a/Core/Core/View/Base/VideoDownloadQualityView.swift b/Core/Core/View/Base/VideoDownloadQualityView.swift index c6e4a6148..d00e0e4e9 100644 --- a/Core/Core/View/Base/VideoDownloadQualityView.swift +++ b/Core/Core/View/Base/VideoDownloadQualityView.swift @@ -64,7 +64,7 @@ public struct VideoDownloadQualityView: View { Spacer() CoreAssets.checkmark.swiftUIImage .renderingMode(.template) - .foregroundColor(.accentColor) + .foregroundColor(Theme.Colors.accentXColor) .opacity(quality == viewModel.selectedDownloadQuality ? 1 : 0) .accessibilityIdentifier("checkmark_image") @@ -163,4 +163,3 @@ public extension DownloadQuality { } } } - diff --git a/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift b/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift index e289b1239..ff0c51ac5 100644 --- a/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift @@ -27,12 +27,15 @@ public struct HandoutsUpdatesDetailView: View { router: CourseRouter, cssInjector: CSSInjector ) { - if handouts != nil { + let noHandouts = handouts == nil && announcements == nil + + if announcements == nil { self.title = CourseLocalization.HandoutsCellHandouts.title } else { self.title = CourseLocalization.HandoutsCellAnnouncements.title } - self.handouts = handouts + + self.handouts = noHandouts ? CourseLocalization.Error.noHandouts : handouts self.announcements = announcements self.router = router self.cssInjector = cssInjector diff --git a/Course/Course/Presentation/Handouts/HandoutsView.swift b/Course/Course/Presentation/Handouts/HandoutsView.swift index cc8dcd325..73a9af0a8 100644 --- a/Course/Course/Presentation/Handouts/HandoutsView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsView.swift @@ -39,9 +39,8 @@ struct HandoutsView: View { } else { VStack(alignment: .leading) { HandoutsItemCell(type: .handouts, onTapAction: { - guard let handouts = viewModel.handouts else { return } viewModel.router.showHandoutsUpdatesView( - handouts: handouts, + handouts: viewModel.handouts, announcements: nil, router: viewModel.router, cssInjector: viewModel.cssInjector) diff --git a/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift b/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift index 176594c23..a868c19fa 100644 --- a/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift +++ b/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift @@ -84,7 +84,7 @@ struct CourseStructureNestedListView: View { if sequential.completion == 1 { CoreAssets.finished.swiftUIImage .renderingMode(.template) - .foregroundColor(.accentColor) + .foregroundColor(Theme.Colors.accentXColor) } else { sequential.type.image } diff --git a/Course/Course/Presentation/Outline/CourseStructure/CourseStructureView.swift b/Course/Course/Presentation/Outline/CourseStructure/CourseStructureView.swift index fad515777..e2dd8646d 100644 --- a/Course/Course/Presentation/Outline/CourseStructure/CourseStructureView.swift +++ b/Course/Course/Presentation/Outline/CourseStructure/CourseStructureView.swift @@ -53,7 +53,7 @@ struct CourseStructureView: View { if child.completion == 1 { CoreAssets.finished.swiftUIImage .renderingMode(.template) - .foregroundColor(.accentColor) + .foregroundColor(Theme.Colors.accentXColor) } else { child.type.image } diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift index c135ab817..db595207d 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift @@ -27,7 +27,7 @@ struct CourseUnitDropDownCell: View { if vertical.completion == 1 { CoreAssets.finished.swiftUIImage .renderingMode(.template) - .foregroundColor(.accentColor) + .foregroundColor(Theme.Colors.accentXColor) } } .frame(width: 25) diff --git a/Course/Course/SwiftGen/Strings.swift b/Course/Course/SwiftGen/Strings.swift index a64b20db6..3439215f8 100644 --- a/Course/Course/SwiftGen/Strings.swift +++ b/Course/Course/SwiftGen/Strings.swift @@ -98,6 +98,8 @@ public enum CourseLocalization { public enum Error { /// Course component not found, please reload public static let componentNotFount = CourseLocalization.tr("Localizable", "ERROR.COMPONENT_NOT_FOUNT", fallback: "Course component not found, please reload") + /// There are currently no handouts for this course + public static let noHandouts = CourseLocalization.tr("Localizable", "ERROR.NO_HANDOUTS", fallback: "There are currently no handouts for this course") /// You are not connected to the Internet. Please check your Internet connection. public static let noInternet = CourseLocalization.tr("Localizable", "ERROR.NO_INTERNET", fallback: "You are not connected to the Internet. Please check your Internet connection.") /// Reload diff --git a/Course/Course/en.lproj/Localizable.strings b/Course/Course/en.lproj/Localizable.strings index ee0be8b1c..e412863b5 100644 --- a/Course/Course/en.lproj/Localizable.strings +++ b/Course/Course/en.lproj/Localizable.strings @@ -28,6 +28,7 @@ "ERROR.NO_INTERNET" = "You are not connected to the Internet. Please check your Internet connection."; "ERROR.RELOAD" = "Reload"; "ERROR.COMPONENT_NOT_FOUNT" = "Course component not found, please reload"; +"ERROR.NO_HANDOUTS" = "There are currently no handouts for this course"; "ALERT.ROTATE_DEVICE" = "Rotate your device to view this video in full screen."; "ALERT.ACCEPT" = "Accept"; diff --git a/Course/Course/uk.lproj/Localizable.strings b/Course/Course/uk.lproj/Localizable.strings index e56d46a37..602a2fd5b 100644 --- a/Course/Course/uk.lproj/Localizable.strings +++ b/Course/Course/uk.lproj/Localizable.strings @@ -27,6 +27,7 @@ "ERROR.NO_INTERNET" = "Ви не підключені до Інтернету. Перевірте підключення до Інтернету і спробуйте ще."; "ERROR.RELOAD" = "Перезавантажити"; "ERROR.COMPONENT_NOT_FOUNT" = "Course component not found, please reload"; +"ERROR.NO_HANDOUTS" = "There are currently no handouts for this course"; "ALERT.ROTATE_DEVICE" = "Поверніть пристрій, щоб переглянути це відео на весь екран."; "ALERT.ACCEPT" = "Accept"; diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift index 8e6048f87..1109507a1 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift @@ -27,7 +27,7 @@ public struct DeleteAccountView: View { ZStack { CoreAssets.bgDelete.swiftUIImage CoreAssets.deleteChar.swiftUIImage - .foregroundColor(.accentColor) + .foregroundColor(Theme.Colors.accentXColor) .offset(y: -31) CoreAssets.deleteEyes.swiftUIImage .offset(x: -7, y: -27) diff --git a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift index 2c0a80d1e..aa04f1d21 100644 --- a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift +++ b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift @@ -165,7 +165,7 @@ struct ProfileSupportInfoView: View { HStack { CoreAssets.checkmark.swiftUIImage .renderingMode(.template) - .foregroundColor(.green) + .foregroundColor(Theme.Colors.success) Text(ProfileLocalization.Settings.upToDate) .font(Theme.Fonts.labelMedium) .foregroundStyle(Theme.Colors.textSecondary) diff --git a/Theme/Theme/Assets.xcassets/Colors/Snackbar/SnackbarInfoAlert.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/Snackbar/SnackbarInfoColor.colorset/Contents.json similarity index 100% rename from Theme/Theme/Assets.xcassets/Colors/Snackbar/SnackbarInfoAlert.colorset/Contents.json rename to Theme/Theme/Assets.xcassets/Colors/Snackbar/SnackbarInfoColor.colorset/Contents.json diff --git a/Theme/Theme/Assets.xcassets/Colors/StyledButton/StyledButtonBackground.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/Snackbar/SnackbarTextColor.colorset/Contents.json similarity index 70% rename from Theme/Theme/Assets.xcassets/Colors/StyledButton/StyledButtonBackground.colorset/Contents.json rename to Theme/Theme/Assets.xcassets/Colors/Snackbar/SnackbarTextColor.colorset/Contents.json index 176f9c50c..6e4fcdc1d 100644 --- a/Theme/Theme/Assets.xcassets/Colors/StyledButton/StyledButtonBackground.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/Snackbar/SnackbarTextColor.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "1.000", - "green" : "0.408", - "red" : "0.235" + "blue" : "1.0", + "green" : "1.0", + "red" : "1.0" } }, "idiom" : "universal" @@ -20,12 +20,12 @@ } ], "color" : { - "color-space" : "display-p3", + "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "1.000", - "green" : "0.482", - "red" : "0.565" + "blue" : "1.0", + "green" : "1.0", + "red" : "1.0" } }, "idiom" : "universal" diff --git a/Theme/Theme/Assets.xcassets/Colors/Success.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/Success.colorset/Contents.json new file mode 100644 index 000000000..65b3786f0 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/Success.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x71", + "green" : "0xA1", + "red" : "0x30" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x4D", + "green" : "0x7D", + "red" : "0x0D" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/SwiftGen/ThemeAssets.swift b/Theme/Theme/SwiftGen/ThemeAssets.swift index 132901ac5..09afbbb6b 100644 --- a/Theme/Theme/SwiftGen/ThemeAssets.swift +++ b/Theme/Theme/SwiftGen/ThemeAssets.swift @@ -38,15 +38,15 @@ public enum ThemeAssets { public static let commentCellBackground = ColorAsset(name: "CommentCellBackground") public static let loginBackground = ColorAsset(name: "LoginBackground") public static let loginNavigationText = ColorAsset(name: "LoginNavigationText") - public static let secondaryButtonBorderColor = ColorAsset(name: "SecondaryButtonBorderColor") public static let secondaryButtonTextColor = ColorAsset(name: "SecondaryButtonTextColor") public static let shadowColor = ColorAsset(name: "ShadowColor") public static let snackbarErrorColor = ColorAsset(name: "SnackbarErrorColor") - public static let snackbarInfoAlert = ColorAsset(name: "SnackbarInfoAlert") + public static let snackbarInfoColor = ColorAsset(name: "SnackbarInfoColor") + public static let snackbarTextColor = ColorAsset(name: "SnackbarTextColor") public static let snackbarWarningColor = ColorAsset(name: "SnackbarWarningColor") public static let splashBackground = ColorAsset(name: "SplashBackground") - public static let styledButtonBackground = ColorAsset(name: "StyledButtonBackground") public static let styledButtonText = ColorAsset(name: "StyledButtonText") + public static let success = ColorAsset(name: "Success") public static let textPrimary = ColorAsset(name: "TextPrimary") public static let textSecondary = ColorAsset(name: "TextSecondary") public static let textSecondaryLight = ColorAsset(name: "TextSecondaryLight") @@ -55,6 +55,7 @@ public enum ThemeAssets { public static let textInputUnfocusedBackground = ColorAsset(name: "TextInputUnfocusedBackground") public static let textInputUnfocusedStroke = ColorAsset(name: "TextInputUnfocusedStroke") public static let navigationBarTintColor = ColorAsset(name: "navigationBarTintColor") + public static let secondaryButtonBorderColor = ColorAsset(name: "secondaryButtonBorderColor") public static let warning = ColorAsset(name: "warning") public static let white = ColorAsset(name: "white") public static let datesSectionBackground = ColorAsset(name: "DatesSectionBackground") diff --git a/Theme/Theme/Theme.swift b/Theme/Theme/Theme.swift index 08fa293e1..ec48bcad6 100644 --- a/Theme/Theme/Theme.swift +++ b/Theme/Theme/Theme.swift @@ -33,7 +33,8 @@ public struct Theme { public private(set) static var shadowColor = ThemeAssets.shadowColor.swiftUIColor public private(set) static var snackbarErrorColor = ThemeAssets.snackbarErrorColor.swiftUIColor public private(set) static var snackbarWarningColor = ThemeAssets.snackbarWarningColor.swiftUIColor - public private(set) static var snackbarInfoAlert = ThemeAssets.snackbarInfoAlert.swiftUIColor + public private(set) static var snackbarInfoColor = ThemeAssets.snackbarInfoColor.swiftUIColor + public private(set) static var snackbarTextColor = ThemeAssets.snackbarTextColor.swiftUIColor public private(set) static var styledButtonText = ThemeAssets.styledButtonText.swiftUIColor public private(set) static var textPrimary = ThemeAssets.textPrimary.swiftUIColor public private(set) static var textSecondary = ThemeAssets.textSecondary.swiftUIColor @@ -54,6 +55,7 @@ public struct Theme { public private(set) static var navigationBarTintColor = ThemeAssets.navigationBarTintColor.swiftUIColor public private(set) static var secondaryButtonBorderColor = ThemeAssets.secondaryButtonBorderColor.swiftUIColor public private(set) static var secondaryButtonTextColor = ThemeAssets.secondaryButtonTextColor.swiftUIColor + public private(set) static var success = ThemeAssets.success.swiftUIColor public static func update( accentColor: Color = ThemeAssets.accentColor.swiftUIColor, @@ -73,7 +75,8 @@ public struct Theme { upcomingTimelineColor: Color = ThemeAssets.upcomingTimelineColor.swiftUIColor, shadowColor: Color = ThemeAssets.shadowColor.swiftUIColor, snackbarErrorColor: Color = ThemeAssets.snackbarErrorColor.swiftUIColor, - snackbarInfoAlert: Color = ThemeAssets.snackbarInfoAlert.swiftUIColor, + snackbarInfoColor: Color = ThemeAssets.snackbarInfoColor.swiftUIColor, + snackbarTextColor: Color = ThemeAssets.snackbarTextColor.swiftUIColor, styledButtonText: Color = ThemeAssets.styledButtonText.swiftUIColor, textPrimary: Color = ThemeAssets.textPrimary.swiftUIColor, textSecondary: Color = ThemeAssets.textSecondary.swiftUIColor, @@ -91,7 +94,8 @@ public struct Theme { datesSectionStroke: Color = ThemeAssets.datesSectionStroke.swiftUIColor, navigationBarTintColor: Color = ThemeAssets.navigationBarTintColor.swiftUIColor, secondaryButtonBorderColor: Color = ThemeAssets.secondaryButtonBorderColor.swiftUIColor, - secondaryButtonTextColor: Color = ThemeAssets.secondaryButtonTextColor.swiftUIColor + secondaryButtonTextColor: Color = ThemeAssets.secondaryButtonTextColor.swiftUIColor, + success: Color = ThemeAssets.success.swiftUIColor ) { self.accentColor = accentColor self.accentXColor = accentXColor @@ -110,7 +114,8 @@ public struct Theme { self.upcomingTimelineColor = upcomingTimelineColor self.shadowColor = shadowColor self.snackbarErrorColor = snackbarErrorColor - self.snackbarInfoAlert = snackbarInfoAlert + self.snackbarInfoColor = snackbarInfoColor + self.snackbarTextColor = snackbarTextColor self.styledButtonText = styledButtonText self.textPrimary = textPrimary self.textSecondary = textSecondary @@ -129,6 +134,7 @@ public struct Theme { self.navigationBarTintColor = navigationBarTintColor self.secondaryButtonBorderColor = secondaryButtonBorderColor self.secondaryButtonTextColor = secondaryButtonTextColor + self.success = success } } From 603b88553f02ef16e7fb95ab2563fd4bca9970a6 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Thu, 29 Feb 2024 10:27:42 +0100 Subject: [PATCH 034/136] fix: small check to prevent error (#310) --- config_script/whitelabel.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/config_script/whitelabel.py b/config_script/whitelabel.py index d7295d4d9..4193606ce 100644 --- a/config_script/whitelabel.py +++ b/config_script/whitelabel.py @@ -114,10 +114,12 @@ def replace_images(self, asset_data): for json_image in json_object["images"]: if "appearances" in json_image: # dark - dark_image_name_original = json_image["filename"] + if "filename" in json_image: + dark_image_name_original = json_image["filename"] else: # light - image_name_original = json_image["filename"] + if "filename" in json_image: + image_name_original = json_image["filename"] has_dark = True if "dark_image_name" in image else False image_name_import = image["image_name"] if "image_name" in image else '' dark_image_name_import = image["dark_image_name"] if "dark_image_name" in image else '' From 4166d0a2b1b2670c39201e179f2c7e932d04c5aa Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Thu, 29 Feb 2024 10:28:06 +0100 Subject: [PATCH 035/136] chore: added checking if connection is available before app review showing (#309) --- OpenEdX/Router.swift | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 582a2772b..839bba866 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -121,8 +121,11 @@ public class Router: AuthorizationRouter, } public func presentAppReview() { - let config = Container.shared.resolve(ConfigProtocol.self)! - let storage = Container.shared.resolve(CoreStorage.self)! + guard let config = Container.shared.resolve(ConfigProtocol.self), + let storage = Container.shared.resolve(CoreStorage.self), + let connectivity = Container.shared.resolve(ConnectivityProtocol.self), + connectivity.isInternetAvaliable + else { return } let vm = AppReviewViewModel(config: config, storage: storage) if vm.shouldShowRatingView() { presentView( From 16cc52e496a89b5ed1647d84ecbbb7c597798078 Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Thu, 29 Feb 2024 14:28:59 +0500 Subject: [PATCH 036/136] chore: set accessibility identifiers to elements (#293) * chore: add IDs to elements of my courses, discovery, program and profile screens * chore: setting identifiers * refactor: revert back accidental commented code --- .../Presentation/Login/SignInView.swift | 1 - .../Registration/SignUpView.swift | 1 - .../Reset Password/ResetPasswordView.swift | 1 - Core/Core/View/Base/CourseCellView.swift | 7 ++++++ Core/Core/View/Base/OfflineSnackBarView.swift | 3 +++ Core/Core/View/Base/SnackBarView.swift | 2 ++ .../View/Base/VideoDownloadQualityView.swift | 12 +++++----- Core/Core/View/Base/WebBrowser.swift | 2 ++ .../Presentation/DashboardView.swift | 6 +++++ .../NativeDiscovery/CourseDetailsView.swift | 11 ++++++++++ .../NativeDiscovery/DiscoveryView.swift | 4 ++++ .../NativeDiscovery/SearchView.swift | 5 +++++ .../WebDiscovery/DiscoveryWebview.swift | 2 ++ .../WebPrograms/ProgramWebviewView.swift | 2 ++ OpenEdX/View/MainScreenView.swift | 6 +++++ .../DeleteAccount/DeleteAccountView.swift | 22 +++++++++++++++---- .../EditProfile/EditProfileView.swift | 20 +++++++++++++---- .../EditProfile/ProfileBottomSheet.swift | 7 +++++- .../Presentation/Profile/ProfileView.swift | 12 ++++++++++ .../Subviews/ProfileSupportInfoView.swift | 20 +++++++++++++---- .../Presentation/Settings/SettingsView.swift | 8 +++++++ .../Settings/VideoQualityView.swift | 3 ++- 22 files changed, 135 insertions(+), 22 deletions(-) diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index 1b63dfabc..35024ef4b 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -187,7 +187,6 @@ public struct SignInView: View { VStack { Spacer() SnackBarView(message: viewModel.errorMessage) - .accessibilityLabel("error_snackbar") }.transition(.move(edge: .bottom)) .onAppear { doAfter(Theme.Timeout.snackbarMessageLongTimeout) { diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index 40a851ef3..4215501a9 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -183,7 +183,6 @@ public struct SignUpView: View { VStack { Spacer() SnackBarView(message: viewModel.errorMessage) - .accessibilityLabel("error_snackbar") }.transition(.move(edge: .bottom)) .onAppear { doAfter(Theme.Timeout.snackbarMessageLongTimeout) { diff --git a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift index 3cec56f4a..8019fc9b4 100644 --- a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift +++ b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift @@ -157,7 +157,6 @@ public struct ResetPasswordView: View { VStack { Spacer() SnackBarView(message: viewModel.errorMessage) - .accessibilityIdentifier("show_error_snackbar") }.transition(.move(edge: .bottom)) .onAppear { doAfter(Theme.Timeout.snackbarMessageLongTimeout) { diff --git a/Core/Core/View/Base/CourseCellView.swift b/Core/Core/View/Base/CourseCellView.swift index e8ce6ca01..2d8ea1c24 100644 --- a/Core/Core/View/Base/CourseCellView.swift +++ b/Core/Core/View/Base/CourseCellView.swift @@ -50,12 +50,14 @@ public struct CourseCellView: View { .clipShape(RoundedRectangle(cornerRadius: Theme.Shapes.cardImageRadius)) .padding(.leading, 3) .accessibilityElement(children: .ignore) + .accessibilityIdentifier("course_image") VStack(alignment: .leading) { Text(courseOrg) .font(Theme.Fonts.labelMedium) .foregroundColor(Theme.Colors.textSecondary) .multilineTextAlignment(.leading) + .accessibilityIdentifier("org_text") Text(courseName) .font(Theme.Fonts.titleSmall) @@ -63,6 +65,7 @@ public struct CourseCellView: View { .lineLimit(type == .discovery ? 3 : 2) .multilineTextAlignment(.leading) .padding(.top, 1) + .accessibilityIdentifier("course_name_text") Spacer() if type == .dashboard { HStack { @@ -70,10 +73,12 @@ public struct CourseCellView: View { Text(courseEnd) .font(Theme.Fonts.labelMedium) .foregroundColor(Theme.Colors.textSecondary) + .accessibilityIdentifier("course_end_text") } else { Text(courseStart) .font(Theme.Fonts.labelMedium) .foregroundColor(Theme.Colors.textSecondary) + .accessibilityIdentifier("course_start_text") } Spacer() CoreAssets.arrowRight16.swiftUIImage.renderingMode(.template) @@ -81,6 +86,7 @@ public struct CourseCellView: View { .frame(width: 16, height: 16) .offset(x: 15) .foregroundColor(Theme.Colors.accentColor) + .accessibilityIdentifier("arrow_image") } } }.padding(.horizontal, 10) @@ -110,6 +116,7 @@ public struct CourseCellView: View { .overlay(Theme.Colors.cardViewStroke) .padding(.vertical, 18) .padding(.horizontal, 3) + .accessibilityIdentifier("devider") } } } diff --git a/Core/Core/View/Base/OfflineSnackBarView.swift b/Core/Core/View/Base/OfflineSnackBarView.swift index b2625f7f8..4928c6936 100644 --- a/Core/Core/View/Base/OfflineSnackBarView.swift +++ b/Core/Core/View/Base/OfflineSnackBarView.swift @@ -28,6 +28,7 @@ public struct OfflineSnackBarView: View { Spacer() HStack(spacing: 12) { Text(CoreLocalization.NoInternet.offline) + .accessibilityIdentifier("no_internet_text") Spacer() Button(CoreLocalization.NoInternet.dismiss, action: { @@ -35,6 +36,7 @@ public struct OfflineSnackBarView: View { dismiss = true } }) + .accessibilityIdentifier("no_internet_dismiss_button") Button(CoreLocalization.NoInternet.reload, action: { Task { @@ -44,6 +46,7 @@ public struct OfflineSnackBarView: View { dismiss = true } }) + .accessibilityIdentifier("no_internet_reload_button") }.padding(.horizontal, 16) .font(Theme.Fonts.titleSmall) .frame(maxWidth: .infinity, maxHeight: OfflineSnackBarView.height) diff --git a/Core/Core/View/Base/SnackBarView.swift b/Core/Core/View/Base/SnackBarView.swift index 65da1cd72..14ed079f4 100644 --- a/Core/Core/View/Base/SnackBarView.swift +++ b/Core/Core/View/Base/SnackBarView.swift @@ -29,6 +29,7 @@ public struct SnackBarView: View { Text(message) .font(Theme.Fonts.titleSmall) .foregroundColor(Theme.Colors.snackbarTextColor) + .accessibilityIdentifier("snackbar_text") Spacer() if let action = action { @@ -36,6 +37,7 @@ public struct SnackBarView: View { action() } .font(Theme.Fonts.titleSmall) + .accessibilityIdentifier("snackbar_button") } }.shadowCardStyle(bgColor: Theme.Colors.snackbarErrorColor, diff --git a/Core/Core/View/Base/VideoDownloadQualityView.swift b/Core/Core/View/Base/VideoDownloadQualityView.swift index d00e0e4e9..6ffa52466 100644 --- a/Core/Core/View/Base/VideoDownloadQualityView.swift +++ b/Core/Core/View/Base/VideoDownloadQualityView.swift @@ -51,9 +51,9 @@ public struct VideoDownloadQualityView: View { ScrollView { VStack(alignment: .leading, spacing: 24) { ForEach(viewModel.downloadQuality, id: \.self) { quality in - Button { + Button(action: { viewModel.selectedDownloadQuality = quality - } label: { + }, label: { HStack { SettingsCell( title: quality.title, @@ -67,11 +67,11 @@ public struct VideoDownloadQualityView: View { .foregroundColor(Theme.Colors.accentXColor) .opacity(quality == viewModel.selectedDownloadQuality ? 1 : 0) .accessibilityIdentifier("checkmark_image") - + } .foregroundColor(Theme.Colors.textPrimary) - } - .accessibilityIdentifier("quality_button_cell") + }) + .accessibilityIdentifier("select_quality_button") Divider() } } @@ -109,10 +109,12 @@ public struct SettingsCell: View { VStack(alignment: .leading) { Text(title) .font(Theme.Fonts.titleMedium) + .accessibilityIdentifier("video_quality_title_text") if let description { Text(description) .font(Theme.Fonts.labelMedium) .foregroundColor(Theme.Colors.textSecondary) + .accessibilityIdentifier("video_quality_des_text") } }.foregroundColor(Theme.Colors.textPrimary) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Core/Core/View/Base/WebBrowser.swift b/Core/Core/View/Base/WebBrowser.swift index dafd884fc..776c14733 100644 --- a/Core/Core/View/Base/WebBrowser.swift +++ b/Core/Core/View/Base/WebBrowser.swift @@ -36,6 +36,7 @@ public struct WebBrowser: View { lineWidth: 8 ) .padding(20) + .accessibilityIdentifier("progress_bar") } .frame(maxWidth: .infinity) } @@ -57,6 +58,7 @@ public struct WebBrowser: View { isLoading: $isLoading, refreshCookies: {} ) + .accessibilityIdentifier("web_browser") } .padding(.top, proxy.safeAreaInsets.top) .padding(.bottom, proxy.safeAreaInsets.bottom) diff --git a/Dashboard/Dashboard/Presentation/DashboardView.swift b/Dashboard/Dashboard/Presentation/DashboardView.swift index 108f1dc59..9c8b81ab5 100644 --- a/Dashboard/Dashboard/Presentation/DashboardView.swift +++ b/Dashboard/Dashboard/Presentation/DashboardView.swift @@ -14,9 +14,11 @@ public struct DashboardView: View { Text(DashboardLocalization.Header.courses) .font(Theme.Fonts.displaySmall) .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("courses_header_text") Text(DashboardLocalization.Header.welcomeBack) .font(Theme.Fonts.titleSmall) .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("courses_welcomeback_text") }.listRowBackground(Color.clear) .padding(.top, 24) .accessibilityElement(children: .ignore) @@ -81,6 +83,7 @@ public struct DashboardView: View { title: course.name ) } + .accessibilityIdentifier("course_item") } // MARK: - ProgressBar if viewModel.nextPage <= viewModel.totalPages { @@ -158,13 +161,16 @@ struct EmptyPageIcon: View { VStack(alignment: .center, spacing: 0) { CoreAssets.dashboardEmptyPage.swiftUIImage .padding(.bottom, 16) + .accessibilityIdentifier("empty_page_image") Text(DashboardLocalization.Empty.title) .font(Theme.Fonts.titleMedium) .foregroundColor(Theme.Colors.textPrimary) .padding(.bottom, 8) + .accessibilityIdentifier("empty_page_title_text") Text(DashboardLocalization.Empty.subtitle) .font(Theme.Fonts.bodySmall) .foregroundColor(Theme.Colors.textSecondary) + .accessibilityIdentifier("empty_page_subtitle_text") } .padding(.top, 200) } diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift index 42e43fd3c..b8a76a400 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift @@ -47,6 +47,7 @@ public struct CourseDetailsView: View { ProgressBar(size: 40, lineWidth: 8) .padding(.top, 200) .padding(.horizontal) + .accessibilityIdentifier("progressbar") }.frame(width: proxy.size.width) } else { RefreshableScrollViewCompat(action: { @@ -131,6 +132,7 @@ public struct CourseDetailsView: View { ProgressBar(size: 40, lineWidth: 8) .padding(.top, 20) .frame(maxWidth: .infinity) + .accessibilityIdentifier("progressbar") } } } @@ -233,12 +235,14 @@ private struct CourseStateView: View { } }) .padding(16) + .accessibilityIdentifier("enroll_button") case .enrollClose: Text(DiscoveryLocalization.Details.enrollmentDateIsOver) .multilineTextAlignment(.center) .font(Theme.Fonts.titleSmall) .cardStyle() .padding(.vertical, 24) + .accessibilityIdentifier("date_over_text") case .alreadyEnrolled: StyledButton(DiscoveryLocalization.Details.viewCourse, action: { if !viewModel.userloggedIn { @@ -264,6 +268,7 @@ private struct CourseStateView: View { } }) .padding(16) + .accessibilityIdentifier("view_course_button") } } } @@ -277,6 +282,7 @@ private struct PlayButton: View { .resizable() .frame(width: 40, height: 40) }) + .accessibilityIdentifier("play_button") } } @@ -288,16 +294,19 @@ private struct CourseTitleView: View { Text(courseDetails.courseDescription ?? "") .font(Theme.Fonts.labelSmall) .padding(.horizontal, 26) + .accessibilityIdentifier("description_text") Text(courseDetails.courseTitle) .font(Theme.Fonts.titleLarge) .padding(.horizontal, 26) + .accessibilityIdentifier("title_text") Text(courseDetails.org) .font(Theme.Fonts.labelMedium) .foregroundColor(Theme.Colors.accentColor) .padding(.horizontal, 26) .padding(.top, 10) + .accessibilityIdentifier("org_text") } } } @@ -335,6 +344,7 @@ private struct CourseBannerView: View { animate = true } } + .accessibilityIdentifier("course_image") if courseDetails.courseVideoURL != nil { PlayButton(action: onPlayButtonTap) } @@ -350,6 +360,7 @@ private struct CourseBannerView: View { animate = true } } + .accessibilityIdentifier("course_image") if courseDetails.courseVideoURL != nil { PlayButton(action: onPlayButtonTap) } diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift index 461a705ea..2b857e0af 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift @@ -26,9 +26,11 @@ public struct DiscoveryView: View { Text(DiscoveryLocalization.Header.title1) .font(Theme.Fonts.displaySmall) .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("title_text") Text(DiscoveryLocalization.Header.title2) .font(Theme.Fonts.titleSmall) .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("subtitle_text") }.listRowBackground(Color.clear) .accessibilityElement(children: .ignore) .accessibilityLabel(DiscoveryLocalization.Header.title1 + DiscoveryLocalization.Header.title2) @@ -56,8 +58,10 @@ public struct DiscoveryView: View { Image(systemName: "magnifyingglass") .padding(.leading, 16) .padding(.top, 1) + .accessibilityIdentifier("search_image") Text(DiscoveryLocalization.search) .foregroundColor(Theme.Colors.textSecondary) + .accessibilityIdentifier("search_text") Spacer() } .onTapGesture { diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift index 782956544..a494ee1ec 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift @@ -44,6 +44,7 @@ public struct SearchView: View { : Theme.Colors.textPrimary ) .accessibilityHidden(true) + .accessibilityIdentifier("search_image") TextField( !viewModel.isSearchActive @@ -58,6 +59,7 @@ public struct SearchView: View { self.focused = true } .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("search_textfields") Spacer() if !viewModel.searchText.trimmingCharacters(in: .whitespaces).isEmpty { Button(action: { viewModel.searchText.removeAll() }, label: { @@ -68,6 +70,7 @@ public struct SearchView: View { .padding(.horizontal) }) .foregroundColor(Theme.Colors.styledButtonText) + .accessibilityIdentifier("search_button") } } .frame(minHeight: 48) @@ -172,9 +175,11 @@ public struct SearchView: View { Text(DiscoveryLocalization.Search.title) .font(Theme.Fonts.displaySmall) .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("title_text") Text(searchDescription(viewModel: viewModel)) .font(Theme.Fonts.titleSmall) .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("description_text") }.listRowBackground(Color.clear) .accessibilityElement(children: .ignore) .accessibilityLabel(DiscoveryLocalization.Search.title + searchDescription(viewModel: viewModel)) diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift index a9d801f4d..fc16cdd3e 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift @@ -93,6 +93,7 @@ public struct DiscoveryWebview: View { refreshCookies: {}, navigationDelegate: viewModel ) + .accessibilityIdentifier("discovery_webview") if isLoading || viewModel.showProgress { HStack(alignment: .center) { @@ -101,6 +102,7 @@ public struct DiscoveryWebview: View { lineWidth: 8 ) .padding(.vertical, proxy.size.height / 2) + .accessibilityIdentifier("progressbar") } .frame(width: proxy.size.width, height: proxy.size.height) } diff --git a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift index 7a710b897..d5a1b4f8f 100644 --- a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift +++ b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift @@ -68,6 +68,7 @@ public struct ProgramWebviewView: View { }, navigationDelegate: viewModel ) + .accessibilityIdentifier("program_webview") if isLoading || viewModel.showProgress || viewModel.updatingCookies { HStack(alignment: .center) { @@ -76,6 +77,7 @@ public struct ProgramWebviewView: View { lineWidth: 8 ) .padding(.vertical, proxy.size.height / 2) + .accessibilityIdentifier("progressbar") } .frame(width: proxy.size.width, height: proxy.size.height) } diff --git a/OpenEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift index 8971d982c..1d0c1a2f6 100644 --- a/OpenEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -68,6 +68,7 @@ struct MainScreenView: View { Text(CoreLocalization.Mainscreen.discovery) } .tag(MainTab.discovery) + .accessibilityIdentifier("discovery_tabitem") } ZStack { @@ -84,6 +85,7 @@ struct MainScreenView: View { Text(CoreLocalization.Mainscreen.dashboard) } .tag(MainTab.dashboard) + .accessibilityIdentifier("dashboard_tabitem") if config?.program.enabled ?? false { ZStack { @@ -94,6 +96,7 @@ struct MainScreenView: View { ) } else if config?.program.type == .native { Text(CoreLocalization.Mainscreen.inDeveloping) + .accessibilityIdentifier("indevelopment_program_text") } if updateAvaliable { @@ -105,6 +108,7 @@ struct MainScreenView: View { Text(CoreLocalization.Mainscreen.programs) } .tag(MainTab.programs) + .accessibilityIdentifier("programs_tabitem") } VStack { @@ -117,6 +121,7 @@ struct MainScreenView: View { Text(CoreLocalization.Mainscreen.profile) } .tag(MainTab.profile) + .accessibilityIdentifier("profile_tabitem") } .navigationBarHidden(false) .navigationBarBackButtonHidden(false) @@ -130,6 +135,7 @@ struct MainScreenView: View { CoreAssets.edit.swiftUIImage.renderingMode(.template) .foregroundColor(Theme.Colors.navigationBarTintColor) }) + .accessibilityIdentifier("edit_profile_button") } else { VStack {} } diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift index 1109507a1..67967fb2f 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift @@ -31,11 +31,17 @@ public struct DeleteAccountView: View { .offset(y: -31) CoreAssets.deleteEyes.swiftUIImage .offset(x: -7, y: -27) + .accessibilityIdentifier("delete_account_image") }.padding(.top, 50) - Text(ProfileLocalization.DeleteAccount.areYouSure) - .foregroundColor(Theme.Colors.navigationBarTintColor) - + Text(ProfileLocalization.DeleteAccount.wantToDelete) - .foregroundColor(Theme.Colors.alert) + + HStack { + Text(ProfileLocalization.DeleteAccount.areYouSure) + .foregroundColor(Theme.Colors.navigationBarTintColor) + + Text(ProfileLocalization.DeleteAccount.wantToDelete) + .foregroundColor(Theme.Colors.alert) + } + .accessibilityIdentifier("are_you_sure_text") + }.multilineTextAlignment(.center) .font(Theme.Fonts.headlineSmall) @@ -44,6 +50,7 @@ public struct DeleteAccountView: View { .font(Theme.Fonts.labelLarge) .multilineTextAlignment(.center) .padding(.top, 16) + .accessibilityIdentifier("delete_account_description_text") // MARK: Password Group { @@ -52,12 +59,14 @@ public struct DeleteAccountView: View { .font(Theme.Fonts.labelLarge) .multilineTextAlignment(.leading) .padding(.top, 16) + .accessibilityIdentifier("password_text") HStack(spacing: 11) { SecureField(ProfileLocalization.DeleteAccount.passwordDescription, text: $viewModel.password) .font(Theme.Fonts.labelLarge) .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("password_textfield") } .padding(.horizontal, 14) .frame(minHeight: 48) @@ -80,6 +89,7 @@ public struct DeleteAccountView: View { .padding(.top, 0) .shake($viewModel.incorrectPassword, onCompletion: { viewModel.incorrectPassword.toggle() }) + .accessibilityIdentifier("incorrect_password_text") }.frame(minWidth: 0, maxWidth: .infinity, @@ -90,6 +100,7 @@ public struct DeleteAccountView: View { ProgressBar(size: 40, lineWidth: 8) .padding(.top, 20) .padding(.horizontal) + .accessibilityIdentifier("progressbar") } else { StyledButton(ProfileLocalization.DeleteAccount.comfirm, action: { Task { @@ -98,6 +109,7 @@ public struct DeleteAccountView: View { }, color: Theme.Colors.accentColor, isActive: viewModel.password.count >= 2) .padding(.top, 18) + .accessibilityIdentifier("delete_account_button") } // MARK: Back to profile @@ -113,6 +125,8 @@ public struct DeleteAccountView: View { .foregroundColor(Theme.Colors.accentColor) } }) + .padding(.top, 35) + .accessibilityIdentifier("back_button") .frame(maxWidth: .infinity, minHeight: 42) .background( Theme.Shapes.buttonShape diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift index ffeecd11b..ce7861ed3 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift @@ -37,6 +37,7 @@ public struct EditProfileView: View { Text(viewModel.profileChanges.profileType.localizedValue.capitalized) .font(Theme.Fonts.titleSmall) .foregroundColor(Theme.Colors.textSecondary) + .accessibilityIdentifier("profile_type_text") Button(action: { withAnimation { showingBottomSheet.toggle() @@ -52,17 +53,22 @@ public struct EditProfileView: View { .foregroundColor(Theme.Colors.white) }.offset(x: 36, y: 50) ) - }).disabled(!viewModel.isEditable) + }) + .disabled(!viewModel.isEditable) + .accessibilityIdentifier("change_profile_image_button") Text(viewModel.userModel.name) .font(Theme.Fonts.headlineSmall) + .accessibilityIdentifier("username_text") Button(ProfileLocalization.switchTo + " " + viewModel.profileChanges.profileType.switchToButtonTitle, action: { viewModel.switchProfile() - }).padding(.vertical, 24) - .font(Theme.Fonts.labelLarge) + }) + .padding(.vertical, 24) + .font(Theme.Fonts.labelLarge) + .accessibilityIdentifier("switch_profile_button") Group { PickerView( @@ -79,6 +85,7 @@ public struct EditProfileView: View { Text(ProfileLocalization.Edit.Fields.aboutMe) .font(Theme.Fonts.titleMedium) + .accessibilityIdentifier("about_text") TextEditor(text: $viewModel.profileChanges.shortBiography) .padding(.horizontal, 12) .padding(.vertical, 4) @@ -95,6 +102,7 @@ public struct EditProfileView: View { Theme.Colors.textInputStroke ) ) + .accessibilityIdentifier("short_bio_textarea") } } } @@ -122,6 +130,7 @@ public struct EditProfileView: View { .font(Theme.Fonts.labelLarge) .foregroundColor(Theme.Colors.alert) .padding(.top, 44) + .accessibilityIdentifier("delete_account_button") Spacer(minLength: 84) }.padding(.horizontal, 24) @@ -197,6 +206,7 @@ public struct EditProfileView: View { ProgressBar(size: 40, lineWidth: 8) .padding(.top, 150) .padding(.horizontal) + .accessibilityIdentifier("progressbar") } } .navigationBarHidden(false) @@ -228,7 +238,9 @@ public struct EditProfileView: View { .font(Theme.Fonts.labelLarge) .foregroundColor(Theme.Colors.accentXColor) } - }).opacity(viewModel.isChanged ? 1 : 0.3) + }) + .opacity(viewModel.isChanged ? 1 : 0.3) + .accessibilityIdentifier("done_button") }) } .background( diff --git a/Profile/Profile/Presentation/EditProfile/ProfileBottomSheet.swift b/Profile/Profile/Presentation/EditProfile/ProfileBottomSheet.swift index fba6c94f8..e6779c0ef 100644 --- a/Profile/Profile/Presentation/EditProfile/ProfileBottomSheet.swift +++ b/Profile/Profile/Presentation/EditProfile/ProfileBottomSheet.swift @@ -76,12 +76,14 @@ struct ProfileBottomSheet: View { .font(Theme.Fonts.titleLarge) .frame(maxWidth: .infinity, alignment: .leading) .padding(.vertical, 14) + .accessibilityIdentifier("profile_bottom_sheet_title_text") button(title: ProfileLocalization.Edit.BottomSheet.select, type: .gallery, action: { openGallery() }) + .accessibilityIdentifier("select_picture_button") button(title: ProfileLocalization.Edit.BottomSheet.remove, type: .remove, @@ -89,6 +91,7 @@ struct ProfileBottomSheet: View { removePhoto() }) .padding(.top, 10) + .accessibilityIdentifier("remove_picture_button") button(title: ProfileLocalization.Edit.BottomSheet.cancel, type: .cancel, @@ -96,7 +99,9 @@ struct ProfileBottomSheet: View { withAnimation { showingBottomSheet = false } - }).padding(.top, 34) + }) + .padding(.top, 34) + .accessibilityIdentifier("cancel_button") }.padding(.horizontal, 24) } diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index 6354d5999..aba4352f4 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -101,17 +101,21 @@ public struct ProfileView: View { ProgressBar(size: 40, lineWidth: 8) .padding(.top, 200) .padding(.horizontal) + .accessibilityIdentifier("progressbar") } else { UserAvatar(url: viewModel.userModel?.avatarUrl ?? "", image: $viewModel.updatedAvatar) .padding(.top, 30) + .accessibilityIdentifier("user_avatar_image") Text(viewModel.userModel?.name ?? "") .font(Theme.Fonts.headlineSmall) .padding(.top, 20) + .accessibilityIdentifier("user_name_text") Text("@\(viewModel.userModel?.username ?? "")") .font(Theme.Fonts.labelLarge) .padding(.top, 4) .foregroundColor(Theme.Colors.textSecondary) .padding(.bottom, 10) + .accessibilityIdentifier("user_username_text") profileInfo VStack(alignment: .leading, spacing: 14) { settings @@ -133,13 +137,16 @@ public struct ProfileView: View { .padding(.horizontal, 24) .font(Theme.Fonts.labelLarge) .foregroundColor(Theme.Colors.textSecondary) + .accessibilityIdentifier("profile_info_text") VStack(alignment: .leading, spacing: 16) { if viewModel.userModel?.yearOfBirth != 0 { HStack { Text(ProfileLocalization.Edit.Fields.yearOfBirth) .foregroundColor(Theme.Colors.textSecondary) + .accessibilityIdentifier("yob_text") Text(String(viewModel.userModel?.yearOfBirth ?? 0)) + .accessibilityIdentifier("yob_value_text") } } if let bio = viewModel.userModel?.shortBiography, bio != "" { @@ -148,6 +155,7 @@ public struct ProfileView: View { .foregroundColor(Theme.Colors.textSecondary) + Text(bio) } + .accessibilityIdentifier("bio_text") } } .accessibilityElement(children: .ignore) @@ -175,6 +183,8 @@ public struct ProfileView: View { .padding(.horizontal, 24) .font(Theme.Fonts.labelLarge) .foregroundColor(Theme.Colors.textSecondary) + .accessibilityIdentifier("settings_text") + VStack(alignment: .leading, spacing: 27) { Button(action: { viewModel.trackProfileVideoSettingsClicked() @@ -186,6 +196,7 @@ public struct ProfileView: View { Image(systemName: "chevron.right") } }) + .accessibilityIdentifier("video_settings_button") } .accessibilityElement(children: .ignore) @@ -226,6 +237,7 @@ public struct ProfileView: View { }) .accessibilityElement(children: .ignore) .accessibilityLabel(ProfileLocalization.logout) + .accessibilityIdentifier("logout_button") } .foregroundColor(Theme.Colors.alert) .cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground, diff --git a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift index aa04f1d21..51c68156f 100644 --- a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift +++ b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift @@ -23,6 +23,8 @@ struct ProfileSupportInfoView: View { .padding(.horizontal, 24) .font(Theme.Fonts.labelLarge) .foregroundColor(Theme.Colors.textSecondary) + .accessibilityIdentifier("support_info_text") + VStack(alignment: .leading, spacing: 24) { viewModel.contactSupport().map(supportInfo) viewModel.config.agreement.tosURL.map(terms) @@ -31,6 +33,7 @@ struct ProfileSupportInfoView: View { viewModel.config.agreement.dataSellContentURL.map(dataSellContent) viewModel.config.faq.map(faq) version + .accessibilityIdentifier("version_info") } .cardStyle( bgColor: Theme.Colors.textInputUnfocusedBackground, @@ -44,7 +47,8 @@ struct ProfileSupportInfoView: View { url: url, title: ProfileLocalization.contact ), - isEmailSupport: true + isEmailSupport: true, + identifier: "contact_support" ) } @@ -55,6 +59,7 @@ struct ProfileSupportInfoView: View { title: ProfileLocalization.terms ) ) + .accessibilityIdentifier("tos") } private func privacy(url: URL) -> some View { @@ -64,6 +69,7 @@ struct ProfileSupportInfoView: View { title: ProfileLocalization.privacy ) ) + .accessibilityIdentifier("privacy_policy") } private func cookiePolicy(url: URL) -> some View { @@ -73,6 +79,7 @@ struct ProfileSupportInfoView: View { title: ProfileLocalization.cookiePolicy ) ) + .accessibilityIdentifier("cookies_policy") } private func dataSellContent(url: URL) -> some View { @@ -82,6 +89,7 @@ struct ProfileSupportInfoView: View { title: ProfileLocalization.doNotSellInformation ) ) + .accessibilityIdentifier("dont_sell_data") } private func faq(url: URL) -> some View { @@ -89,7 +97,8 @@ struct ProfileSupportInfoView: View { linkViewModel: .init( url: url, title: ProfileLocalization.faqTitle - ) + ), + identifier: "view_faq" ) } @@ -118,7 +127,7 @@ struct ProfileSupportInfoView: View { } @ViewBuilder - private func button(linkViewModel: LinkViewModel, isEmailSupport: Bool = false) -> some View { + private func button(linkViewModel: LinkViewModel, isEmailSupport: Bool = false, identifier: String) -> some View { Button { guard UIApplication.shared.canOpenURL(linkViewModel.url) else { viewModel.errorMessage = isEmailSupport ? @@ -140,6 +149,7 @@ struct ProfileSupportInfoView: View { .foregroundColor(.primary) .accessibilityElement(children: .ignore) .accessibilityLabel(linkViewModel.title) + .accessibilityIdentifier(identifier) Rectangle() .frame(height: 1) .foregroundColor(Theme.Colors.textSecondary) @@ -189,7 +199,9 @@ struct ProfileSupportInfoView: View { } } - }).disabled(viewModel.versionState == .actual) + }) + .disabled(viewModel.versionState == .actual) + .accessibilityIdentifier("version_button") } } diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index 33eba42c2..7dc184467 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -29,6 +29,7 @@ public struct SettingsView: View { ProgressBar(size: 40, lineWidth: 8) .padding(.top, 200) .padding(.horizontal) + .accessibilityIdentifier("progressbar") } else { // MARK: Wi-fi HStack { @@ -39,6 +40,7 @@ public struct SettingsView: View { Toggle(isOn: $viewModel.wifiOnly, label: {}) .toggleStyle(SwitchToggleStyle(tint: Theme.Colors.accentColor)) .frame(width: 50) + .accessibilityIdentifier("download_agreement_switch") }.foregroundColor(Theme.Colors.textPrimary) Divider() @@ -50,10 +52,12 @@ public struct SettingsView: View { SettingsCell(title: ProfileLocalization.Settings.videoQualityTitle, description: viewModel.selectedQuality.settingsDescription()) }) + .accessibilityIdentifier("video_stream_quality_button") // Spacer() Image(systemName: "chevron.right") .padding(.trailing, 12) .frame(width: 10) + .accessibilityIdentifier("video_stream_quality_image") } Divider() @@ -70,10 +74,12 @@ public struct SettingsView: View { description: viewModel.userSettings.downloadQuality.settingsDescription ) } + .accessibilityIdentifier("video_download_quality_button") // Spacer() Image(systemName: "chevron.right") .padding(.trailing, 12) .frame(width: 10) + .accessibilityIdentifier("video_download_quality_image") } Divider() } @@ -147,10 +153,12 @@ public struct SettingsCell: View { VStack(alignment: .leading) { Text(title) .font(Theme.Fonts.titleMedium) + .accessibilityIdentifier("video_settings_text") if let description { Text(description) .font(Theme.Fonts.labelMedium) .foregroundColor(Theme.Colors.textSecondary) + .accessibilityIdentifier("video_settings_sub_text") } }.foregroundColor(Theme.Colors.textPrimary) .frame(maxWidth: .infinity, alignment: .leading) diff --git a/Profile/Profile/Presentation/Settings/VideoQualityView.swift b/Profile/Profile/Presentation/Settings/VideoQualityView.swift index 8530b2dfd..dcb9b66eb 100644 --- a/Profile/Profile/Presentation/Settings/VideoQualityView.swift +++ b/Profile/Profile/Presentation/Settings/VideoQualityView.swift @@ -28,6 +28,7 @@ public struct VideoQualityView: View { ProgressBar(size: 40, lineWidth: 8) .padding(.top, 200) .padding(.horizontal) + .accessibilityIdentifier("progressbar") } else { ForEach(viewModel.quality, id: \.offset) { _, quality in @@ -44,9 +45,9 @@ public struct VideoQualityView: View { .renderingMode(.template) .foregroundColor(Theme.Colors.accentXColor) .opacity(quality == viewModel.selectedQuality ? 1 : 0) - }.foregroundColor(Theme.Colors.textPrimary) }) + .accessibilityIdentifier("select_quality_button") Divider() } } From 0d99facaf8f40c4429074ef02562ee7950ff041e Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Thu, 29 Feb 2024 14:49:10 +0500 Subject: [PATCH 037/136] chore: use custom fonts where missing --- .../Authorization/Presentation/Login/SignInView.swift | 3 +++ .../Presentation/Registration/SignUpView.swift | 1 + .../Presentation/SocialAuth/SocialAuthView.swift | 3 ++- .../Presentation/Startup/StartupView.swift | 1 + Core/Core/View/Base/PickerMenu.swift | 6 +++++- Core/Core/View/Base/RegistrationTextField.swift | 2 ++ .../Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift | 4 ++-- Core/Core/View/Base/SocialAuthButton.swift | 1 + .../DropdownList/CourseUnitDropDownTitle.swift | 2 ++ .../Presentation/Comments/Base/ParentCommentView.swift | 1 + .../CreateNewThread/CreateNewThreadView.swift | 1 + .../DiscussionTopics/DiscussionSearchTopicsView.swift | 1 + .../DiscussionTopics/DiscussionTopicsView.swift | 1 + OpenEdX/View/MainScreenView.swift | 5 +++++ Theme/Theme/SwiftGen/ThemeAssets.swift | 2 +- Theme/Theme/Theme.swift | 10 ++++++++++ 16 files changed, 39 insertions(+), 5 deletions(-) diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index 1b63dfabc..6c18775ce 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -75,6 +75,7 @@ public struct SignInView: View { .foregroundColor(Theme.Colors.textPrimary) .accessibilityIdentifier("username_text") TextField(AuthLocalization.SignIn.emailOrUsername, text: $email) + .font(Theme.Fonts.bodyMedium) .keyboardType(.emailAddress) .textContentType(.emailAddress) .autocapitalization(.none) @@ -97,6 +98,7 @@ public struct SignInView: View { .padding(.top, 18) .accessibilityIdentifier("password_text") SecureField(AuthLocalization.SignIn.password, text: $password) + .font(Theme.Fonts.bodyMedium) .padding(.all, 14) .background( Theme.Shapes.textInputShape @@ -123,6 +125,7 @@ public struct SignInView: View { viewModel.trackForgotPasswordClicked() viewModel.router.showForgotPasswordScreen() } + .font(Theme.Fonts.bodyMedium) .foregroundColor(Theme.Colors.accentColor) .padding(.top, 0) .accessibilityIdentifier("forgot_password_button") diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index 40a851ef3..ca68dc686 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -111,6 +111,7 @@ public struct SignUpView: View { Text(disclosureGroupOpen ? AuthLocalization.SignUp.hideFields : AuthLocalization.SignUp.showFields) + .font(Theme.Fonts.labelLarge) } .accessibilityLabel("optional_fields_text") .padding(.top, 10) diff --git a/Authorization/Authorization/Presentation/SocialAuth/SocialAuthView.swift b/Authorization/Authorization/Presentation/SocialAuth/SocialAuthView.swift index f5190cf4e..6a14f8d39 100644 --- a/Authorization/Authorization/Presentation/SocialAuth/SocialAuthView.swift +++ b/Authorization/Authorization/Presentation/SocialAuth/SocialAuthView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import Theme struct SocialAuthView: View { @@ -51,7 +52,7 @@ struct SocialAuthView: View { HStack { Text("\(AuthLocalization.or) \(title.lowercased()):") .padding(.vertical, 20) - .font(.system(size: 17, weight: .medium)) + .font(Theme.Fonts.labelLarge) .accessibilityIdentifier("social_auth_title_text") Spacer() } diff --git a/Authorization/Authorization/Presentation/Startup/StartupView.swift b/Authorization/Authorization/Presentation/Startup/StartupView.swift index db0eff655..d5fdca04d 100644 --- a/Authorization/Authorization/Presentation/Startup/StartupView.swift +++ b/Authorization/Authorization/Presentation/Startup/StartupView.swift @@ -66,6 +66,7 @@ public struct StartupView: View { .autocorrectionDisabled() .frame(minHeight: 50) .submitLabel(.search) + .font(Theme.Fonts.bodyMedium) .accessibilityIdentifier("explore_courses_textfield") }.overlay( diff --git a/Core/Core/View/Base/PickerMenu.swift b/Core/Core/View/Base/PickerMenu.swift index 849f99ae4..2066a8aa0 100644 --- a/Core/Core/View/Base/PickerMenu.swift +++ b/Core/Core/View/Base/PickerMenu.swift @@ -82,20 +82,23 @@ public struct PickerMenu: View { Text(titleText) .foregroundColor(Theme.Colors.textPrimary) .accessibilityIdentifier("picker_title_text") + .font(Theme.Fonts.bodyMedium) TextField(CoreLocalization.Picker.search, text: $search) .padding(.all, 8) + .font(Theme.Fonts.bodySmall) .background(Theme.Colors.textInputStroke.cornerRadius(6)) .accessibilityIdentifier("picker_search_textfield") Picker("", selection: $selectedItem) { ForEach(filteredItems, id: \.self) { item in Text(item.value) .foregroundColor(Theme.Colors.textPrimary) + .font(Theme.Fonts.bodyMedium) } } .pickerStyle(.wheel) .accessibilityIdentifier("picker") } - .frame(minWidth: 0, + .frame(minWidth: 0, maxWidth: (idiom == .pad || (idiom == .phone && isHorizontal)) ? ipadPickerWidth : .infinity) @@ -114,6 +117,7 @@ public struct PickerMenu: View { router.dismiss(animated: true) }) { Text(CoreLocalization.Picker.accept) + .font(Theme.Fonts.bodyMedium) .foregroundColor(Theme.Colors.textPrimary) .frame(minWidth: 0, maxWidth: (idiom == .pad || (idiom == .phone && isHorizontal)) diff --git a/Core/Core/View/Base/RegistrationTextField.swift b/Core/Core/View/Base/RegistrationTextField.swift index 70467643f..b2e8ffcb0 100644 --- a/Core/Core/View/Base/RegistrationTextField.swift +++ b/Core/Core/View/Base/RegistrationTextField.swift @@ -42,6 +42,7 @@ public struct RegistrationTextField: View { } if isTextArea { TextEditor(text: $config.text) + .font(Theme.Fonts.bodyMedium) .padding(.horizontal, 12) .padding(.vertical, 4) .frame(height: 100) @@ -87,6 +88,7 @@ public struct RegistrationTextField: View { .accessibilityIdentifier("\(config.field.name)_textfield") } else { TextField(placeholder, text: $config.text) + .font(Theme.Fonts.bodyMedium) .keyboardType(keyboardType) .textContentType(textContentType) .autocapitalization(.none) diff --git a/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift index 0c89f0811..1408b5248 100644 --- a/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift +++ b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift @@ -160,8 +160,8 @@ extension ScrollSlidingTabBar { } public static let `default` = Style( - font: .body, - selectedFont: .body.bold(), + font: Theme.Fonts.bodyMedium, + selectedFont: Theme.Fonts.titleSmall, activeAccentColor: Theme.Colors.accentColor, inactiveAccentColor: Theme.Colors.textSecondary, indicatorHeight: 2, diff --git a/Core/Core/View/Base/SocialAuthButton.swift b/Core/Core/View/Base/SocialAuthButton.swift index 3ebd367e7..22f4f25dc 100644 --- a/Core/Core/View/Base/SocialAuthButton.swift +++ b/Core/Core/View/Base/SocialAuthButton.swift @@ -47,6 +47,7 @@ public struct SocialAuthButton: View { Text(title) .foregroundStyle(textColor) .padding(.leading, 10) + .font(Theme.Fonts.bodyMedium) Spacer() } icon: { image.padding(.leading, 10) diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownTitle.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownTitle.swift index 79b1e193f..13c31d3a4 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownTitle.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownTitle.swift @@ -6,6 +6,7 @@ // import SwiftUI +import Theme struct CourseUnitDropDownTitle: View { var title: String @@ -23,6 +24,7 @@ struct CourseUnitDropDownTitle: View { Text(title) .opacity(showDropdown ? 0.7 : 1.0) .lineLimit(1) + .font(Theme.Fonts.bodySmall) if isAvailable { Image(systemName: "chevron.down") .dropdownArrowRotationAnimation(value: showDropdown) diff --git a/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift b/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift index 15ced3ce7..1168f91a5 100644 --- a/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift +++ b/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift @@ -67,6 +67,7 @@ public struct ParentCommentView: View { Text(comments.followed ? DiscussionLocalization.Comment.unfollow : DiscussionLocalization.Comment.follow) + .font(Theme.Fonts.bodyMedium) }).foregroundColor(comments.followed ? Theme.Colors.accentColor : Theme.Colors.textSecondary) diff --git a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift index f6f6a0dc4..265494791 100644 --- a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift +++ b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift @@ -65,6 +65,7 @@ public struct CreateNewThreadView: View { Picker("", selection: $postType) { ForEach(postTypes, id: \.self) { Text($0.localizedValue.capitalized) + .font(Theme.Fonts.bodySmall) } }.pickerStyle(.segmented) .frame(maxWidth: .infinity, maxHeight: 40) diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift index 8091016fe..637f057be 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift @@ -52,6 +52,7 @@ public struct DiscussionSearchTopicsView: View { self.focused = true } .foregroundColor(Theme.Colors.textPrimary) + .font(Theme.Fonts.bodyMedium) Spacer() if !viewModel.searchText.trimmingCharacters(in: .whitespaces).isEmpty { Button(action: { viewModel.searchText.removeAll() }, label: { diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift index e242b58d9..dcff23388 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift @@ -32,6 +32,7 @@ public struct DiscussionTopicsView: View { .padding(.top, 1) Text(DiscussionLocalization.Topics.search) .foregroundColor(Theme.Colors.textSecondary) + .font(Theme.Fonts.bodyMedium) Spacer() } .frame(maxWidth: 532) diff --git a/OpenEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift index 8971d982c..175b715e4 100644 --- a/OpenEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -37,6 +37,11 @@ struct MainScreenView: View { UITabBar.appearance().barTintColor = UIColor(Theme.Colors.textInputUnfocusedBackground) UITabBar.appearance().backgroundColor = UIColor(Theme.Colors.textInputUnfocusedBackground) UITabBar.appearance().unselectedItemTintColor = UIColor(Theme.Colors.textSecondaryLight) + + UITabBarItem.appearance().setTitleTextAttributes( + [NSAttributedString.Key.font: Theme.UIFonts.labelSmall()], + for: .normal + ) } var body: some View { diff --git a/Theme/Theme/SwiftGen/ThemeAssets.swift b/Theme/Theme/SwiftGen/ThemeAssets.swift index 09afbbb6b..19d67ddf2 100644 --- a/Theme/Theme/SwiftGen/ThemeAssets.swift +++ b/Theme/Theme/SwiftGen/ThemeAssets.swift @@ -38,6 +38,7 @@ public enum ThemeAssets { public static let commentCellBackground = ColorAsset(name: "CommentCellBackground") public static let loginBackground = ColorAsset(name: "LoginBackground") public static let loginNavigationText = ColorAsset(name: "LoginNavigationText") + public static let secondaryButtonBorderColor = ColorAsset(name: "SecondaryButtonBorderColor") public static let secondaryButtonTextColor = ColorAsset(name: "SecondaryButtonTextColor") public static let shadowColor = ColorAsset(name: "ShadowColor") public static let snackbarErrorColor = ColorAsset(name: "SnackbarErrorColor") @@ -55,7 +56,6 @@ public enum ThemeAssets { public static let textInputUnfocusedBackground = ColorAsset(name: "TextInputUnfocusedBackground") public static let textInputUnfocusedStroke = ColorAsset(name: "TextInputUnfocusedStroke") public static let navigationBarTintColor = ColorAsset(name: "navigationBarTintColor") - public static let secondaryButtonBorderColor = ColorAsset(name: "secondaryButtonBorderColor") public static let warning = ColorAsset(name: "warning") public static let white = ColorAsset(name: "white") public static let datesSectionBackground = ColorAsset(name: "DatesSectionBackground") diff --git a/Theme/Theme/Theme.swift b/Theme/Theme/Theme.swift index ec48bcad6..6d0a9f459 100644 --- a/Theme/Theme/Theme.swift +++ b/Theme/Theme/Theme.swift @@ -181,6 +181,16 @@ public struct Theme { public static let labelSmall: Font = .custom(fontsParser.fontName(for: .regular), size: 10) } + public struct UIFonts { + public static func labelSmall() -> UIFont { + guard let font = UIFont(name: fontsParser.fontName(for: .regular), size: 10) else { + assert(false, "Could not find the required font") + } + + return font + } + } + public struct Shapes { public static var isRoundedCorners: Bool = true public static let screenBackgroundRadius = 24.0 From 633a3158ea8a106b2ad410c2d2127a25e870ffe5 Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Thu, 29 Feb 2024 14:53:20 +0500 Subject: [PATCH 038/136] refactor: add light logo as universal image as its a svg --- .../appLogoLight.imageset/Contents.json | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/Theme/Theme/Assets.xcassets/appLogoLight.imageset/Contents.json b/Theme/Theme/Assets.xcassets/appLogoLight.imageset/Contents.json index 0c3881647..7443b6fbe 100644 --- a/Theme/Theme/Assets.xcassets/appLogoLight.imageset/Contents.json +++ b/Theme/Theme/Assets.xcassets/appLogoLight.imageset/Contents.json @@ -1,17 +1,8 @@ { "images" : [ - { - "idiom" : "universal", - "scale" : "1x" - }, { "filename" : "appLogoWhite.svg", - "idiom" : "universal", - "scale" : "2x" - }, - { - "idiom" : "universal", - "scale" : "3x" + "idiom" : "universal" } ], "info" : { From 04239ca35de78ad05b0f96d45c6ebe487632daca Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Thu, 29 Feb 2024 15:11:22 +0500 Subject: [PATCH 039/136] refactor: moving colors inside under folder Colors --- .../Theme/Assets.xcassets/{ => Colors}/CourseDates/Contents.json | 0 .../CourseDates/DatesSectionBackground.colorset/Contents.json | 0 .../CourseDates/DatesSectionStroke.colorset/Contents.json | 0 .../CourseDates/NextWeekTimelineColor.colorset/Contents.json | 0 .../CourseDates/ThisWeekTimelineColor.colorset/Contents.json | 0 .../CourseDates/TodayTimelineColor.colorset/Contents.json | 0 .../CourseDates/UpcomingTimelineColor.colorset/Contents.json | 0 .../CourseDates/pastDueTimelineColor.colorset/Contents.json | 0 .../Theme/Assets.xcassets/{ => Colors}/ProgressLine/Contents.json | 0 .../{ => Colors}/ProgressLine/OnProgress.colorset/Contents.json | 0 .../{ => Colors}/ProgressLine/ProgressDone.colorset/Contents.json | 0 .../{ => Colors}/ProgressLine/ProgressSkip.colorset/Contents.json | 0 .../ProgressLine/SelectedAndDone.colorset/Contents.json | 0 13 files changed, 0 insertions(+), 0 deletions(-) rename Theme/Theme/Assets.xcassets/{ => Colors}/CourseDates/Contents.json (100%) rename Theme/Theme/Assets.xcassets/{ => Colors}/CourseDates/DatesSectionBackground.colorset/Contents.json (100%) rename Theme/Theme/Assets.xcassets/{ => Colors}/CourseDates/DatesSectionStroke.colorset/Contents.json (100%) rename Theme/Theme/Assets.xcassets/{ => Colors}/CourseDates/NextWeekTimelineColor.colorset/Contents.json (100%) rename Theme/Theme/Assets.xcassets/{ => Colors}/CourseDates/ThisWeekTimelineColor.colorset/Contents.json (100%) rename Theme/Theme/Assets.xcassets/{ => Colors}/CourseDates/TodayTimelineColor.colorset/Contents.json (100%) rename Theme/Theme/Assets.xcassets/{ => Colors}/CourseDates/UpcomingTimelineColor.colorset/Contents.json (100%) rename Theme/Theme/Assets.xcassets/{ => Colors}/CourseDates/pastDueTimelineColor.colorset/Contents.json (100%) rename Theme/Theme/Assets.xcassets/{ => Colors}/ProgressLine/Contents.json (100%) rename Theme/Theme/Assets.xcassets/{ => Colors}/ProgressLine/OnProgress.colorset/Contents.json (100%) rename Theme/Theme/Assets.xcassets/{ => Colors}/ProgressLine/ProgressDone.colorset/Contents.json (100%) rename Theme/Theme/Assets.xcassets/{ => Colors}/ProgressLine/ProgressSkip.colorset/Contents.json (100%) rename Theme/Theme/Assets.xcassets/{ => Colors}/ProgressLine/SelectedAndDone.colorset/Contents.json (100%) diff --git a/Theme/Theme/Assets.xcassets/CourseDates/Contents.json b/Theme/Theme/Assets.xcassets/Colors/CourseDates/Contents.json similarity index 100% rename from Theme/Theme/Assets.xcassets/CourseDates/Contents.json rename to Theme/Theme/Assets.xcassets/Colors/CourseDates/Contents.json diff --git a/Theme/Theme/Assets.xcassets/CourseDates/DatesSectionBackground.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/CourseDates/DatesSectionBackground.colorset/Contents.json similarity index 100% rename from Theme/Theme/Assets.xcassets/CourseDates/DatesSectionBackground.colorset/Contents.json rename to Theme/Theme/Assets.xcassets/Colors/CourseDates/DatesSectionBackground.colorset/Contents.json diff --git a/Theme/Theme/Assets.xcassets/CourseDates/DatesSectionStroke.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/CourseDates/DatesSectionStroke.colorset/Contents.json similarity index 100% rename from Theme/Theme/Assets.xcassets/CourseDates/DatesSectionStroke.colorset/Contents.json rename to Theme/Theme/Assets.xcassets/Colors/CourseDates/DatesSectionStroke.colorset/Contents.json diff --git a/Theme/Theme/Assets.xcassets/CourseDates/NextWeekTimelineColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/CourseDates/NextWeekTimelineColor.colorset/Contents.json similarity index 100% rename from Theme/Theme/Assets.xcassets/CourseDates/NextWeekTimelineColor.colorset/Contents.json rename to Theme/Theme/Assets.xcassets/Colors/CourseDates/NextWeekTimelineColor.colorset/Contents.json diff --git a/Theme/Theme/Assets.xcassets/CourseDates/ThisWeekTimelineColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/CourseDates/ThisWeekTimelineColor.colorset/Contents.json similarity index 100% rename from Theme/Theme/Assets.xcassets/CourseDates/ThisWeekTimelineColor.colorset/Contents.json rename to Theme/Theme/Assets.xcassets/Colors/CourseDates/ThisWeekTimelineColor.colorset/Contents.json diff --git a/Theme/Theme/Assets.xcassets/CourseDates/TodayTimelineColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/CourseDates/TodayTimelineColor.colorset/Contents.json similarity index 100% rename from Theme/Theme/Assets.xcassets/CourseDates/TodayTimelineColor.colorset/Contents.json rename to Theme/Theme/Assets.xcassets/Colors/CourseDates/TodayTimelineColor.colorset/Contents.json diff --git a/Theme/Theme/Assets.xcassets/CourseDates/UpcomingTimelineColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/CourseDates/UpcomingTimelineColor.colorset/Contents.json similarity index 100% rename from Theme/Theme/Assets.xcassets/CourseDates/UpcomingTimelineColor.colorset/Contents.json rename to Theme/Theme/Assets.xcassets/Colors/CourseDates/UpcomingTimelineColor.colorset/Contents.json diff --git a/Theme/Theme/Assets.xcassets/CourseDates/pastDueTimelineColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/CourseDates/pastDueTimelineColor.colorset/Contents.json similarity index 100% rename from Theme/Theme/Assets.xcassets/CourseDates/pastDueTimelineColor.colorset/Contents.json rename to Theme/Theme/Assets.xcassets/Colors/CourseDates/pastDueTimelineColor.colorset/Contents.json diff --git a/Theme/Theme/Assets.xcassets/ProgressLine/Contents.json b/Theme/Theme/Assets.xcassets/Colors/ProgressLine/Contents.json similarity index 100% rename from Theme/Theme/Assets.xcassets/ProgressLine/Contents.json rename to Theme/Theme/Assets.xcassets/Colors/ProgressLine/Contents.json diff --git a/Theme/Theme/Assets.xcassets/ProgressLine/OnProgress.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/ProgressLine/OnProgress.colorset/Contents.json similarity index 100% rename from Theme/Theme/Assets.xcassets/ProgressLine/OnProgress.colorset/Contents.json rename to Theme/Theme/Assets.xcassets/Colors/ProgressLine/OnProgress.colorset/Contents.json diff --git a/Theme/Theme/Assets.xcassets/ProgressLine/ProgressDone.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/ProgressLine/ProgressDone.colorset/Contents.json similarity index 100% rename from Theme/Theme/Assets.xcassets/ProgressLine/ProgressDone.colorset/Contents.json rename to Theme/Theme/Assets.xcassets/Colors/ProgressLine/ProgressDone.colorset/Contents.json diff --git a/Theme/Theme/Assets.xcassets/ProgressLine/ProgressSkip.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/ProgressLine/ProgressSkip.colorset/Contents.json similarity index 100% rename from Theme/Theme/Assets.xcassets/ProgressLine/ProgressSkip.colorset/Contents.json rename to Theme/Theme/Assets.xcassets/Colors/ProgressLine/ProgressSkip.colorset/Contents.json diff --git a/Theme/Theme/Assets.xcassets/ProgressLine/SelectedAndDone.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/ProgressLine/SelectedAndDone.colorset/Contents.json similarity index 100% rename from Theme/Theme/Assets.xcassets/ProgressLine/SelectedAndDone.colorset/Contents.json rename to Theme/Theme/Assets.xcassets/Colors/ProgressLine/SelectedAndDone.colorset/Contents.json From eda0293aadda5ced3a6b72bbf548bef149034948 Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Thu, 29 Feb 2024 17:53:02 +0500 Subject: [PATCH 040/136] chore: use custom font for navigation bar title and segment control --- .../Extensions/UIApplicationExtension.swift | 13 ++++++++++- Theme/Theme/SwiftGen/ThemeAssets.swift | 22 +++++++++---------- Theme/Theme/Theme.swift | 16 ++++++++++++++ 3 files changed, 39 insertions(+), 12 deletions(-) diff --git a/Core/Core/Extensions/UIApplicationExtension.swift b/Core/Core/Extensions/UIApplicationExtension.swift index 871a60b7b..cb3dc37ca 100644 --- a/Core/Core/Extensions/UIApplicationExtension.swift +++ b/Core/Core/Extensions/UIApplicationExtension.swift @@ -48,7 +48,18 @@ extension UINavigationController { navigationBar.backIndicatorImage = image.withTintColor(Theme.UIColors.accentXColor) navigationBar.backItem?.backButtonTitle = " " navigationBar.backIndicatorTransitionMaskImage = image.withTintColor(Theme.UIColors.accentXColor) - navigationBar.titleTextAttributes = [.foregroundColor: Theme.UIColors.navigationBarTintColor] + navigationBar.titleTextAttributes = [ + .foregroundColor: Theme.UIColors.navigationBarTintColor, + .font: Theme.UIFonts.titleMedium() + ] + + UISegmentedControl.appearance().setTitleTextAttributes( + [ + .foregroundColor: Theme.Colors.white.uiColor(), + .font: Theme.UIFonts.labelLarge() + ], + for: .normal) + UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = Theme.UIColors.accentColor } } diff --git a/Theme/Theme/SwiftGen/ThemeAssets.swift b/Theme/Theme/SwiftGen/ThemeAssets.swift index 19d67ddf2..8d352f649 100644 --- a/Theme/Theme/SwiftGen/ThemeAssets.swift +++ b/Theme/Theme/SwiftGen/ThemeAssets.swift @@ -36,8 +36,19 @@ public enum ThemeAssets { public static let cardViewStroke = ColorAsset(name: "CardViewStroke") public static let certificateForeground = ColorAsset(name: "CertificateForeground") public static let commentCellBackground = ColorAsset(name: "CommentCellBackground") + public static let datesSectionBackground = ColorAsset(name: "DatesSectionBackground") + public static let datesSectionStroke = ColorAsset(name: "DatesSectionStroke") + public static let nextWeekTimelineColor = ColorAsset(name: "NextWeekTimelineColor") + public static let thisWeekTimelineColor = ColorAsset(name: "ThisWeekTimelineColor") + public static let todayTimelineColor = ColorAsset(name: "TodayTimelineColor") + public static let upcomingTimelineColor = ColorAsset(name: "UpcomingTimelineColor") + public static let pastDueTimelineColor = ColorAsset(name: "pastDueTimelineColor") public static let loginBackground = ColorAsset(name: "LoginBackground") public static let loginNavigationText = ColorAsset(name: "LoginNavigationText") + public static let onProgress = ColorAsset(name: "OnProgress") + public static let progressDone = ColorAsset(name: "ProgressDone") + public static let progressSkip = ColorAsset(name: "ProgressSkip") + public static let selectedAndDone = ColorAsset(name: "SelectedAndDone") public static let secondaryButtonBorderColor = ColorAsset(name: "SecondaryButtonBorderColor") public static let secondaryButtonTextColor = ColorAsset(name: "SecondaryButtonTextColor") public static let shadowColor = ColorAsset(name: "ShadowColor") @@ -58,17 +69,6 @@ public enum ThemeAssets { public static let navigationBarTintColor = ColorAsset(name: "navigationBarTintColor") public static let warning = ColorAsset(name: "warning") public static let white = ColorAsset(name: "white") - public static let datesSectionBackground = ColorAsset(name: "DatesSectionBackground") - public static let datesSectionStroke = ColorAsset(name: "DatesSectionStroke") - public static let nextWeekTimelineColor = ColorAsset(name: "NextWeekTimelineColor") - public static let thisWeekTimelineColor = ColorAsset(name: "ThisWeekTimelineColor") - public static let todayTimelineColor = ColorAsset(name: "TodayTimelineColor") - public static let upcomingTimelineColor = ColorAsset(name: "UpcomingTimelineColor") - public static let pastDueTimelineColor = ColorAsset(name: "pastDueTimelineColor") - public static let onProgress = ColorAsset(name: "OnProgress") - public static let progressDone = ColorAsset(name: "ProgressDone") - public static let progressSkip = ColorAsset(name: "ProgressSkip") - public static let selectedAndDone = ColorAsset(name: "SelectedAndDone") public static let appLogo = ImageAsset(name: "appLogo") public static let appLogoLight = ImageAsset(name: "appLogoLight") } diff --git a/Theme/Theme/Theme.swift b/Theme/Theme/Theme.swift index 6d0a9f459..75110db9a 100644 --- a/Theme/Theme/Theme.swift +++ b/Theme/Theme/Theme.swift @@ -189,6 +189,22 @@ public struct Theme { return font } + + public static func labelLarge() -> UIFont { + guard let font = UIFont(name: fontsParser.fontName(for: .regular), size: 14) else { + assert(false, "Could not find the required font") + } + + return font + } + + public static func titleMedium() -> UIFont { + guard let font = UIFont(name: fontsParser.fontName(for: .semiBold), size: 18) else { + assert(false, "Could not find the required font") + } + + return font + } } public struct Shapes { From 93ca6a28034a1d3b565179252ff6107db24e0643 Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Fri, 1 Mar 2024 10:39:46 +0500 Subject: [PATCH 041/136] refactor: color combination improvements --- Authorization/Authorization/Presentation/Base/FieldsView.swift | 2 +- Authorization/Authorization/Presentation/Login/SignInView.swift | 2 +- Core/Core/View/Base/PickerMenu.swift | 1 + 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Authorization/Authorization/Presentation/Base/FieldsView.swift b/Authorization/Authorization/Presentation/Base/FieldsView.swift index d05eac80f..0bc9f2ea2 100644 --- a/Authorization/Authorization/Presentation/Base/FieldsView.swift +++ b/Authorization/Authorization/Presentation/Base/FieldsView.swift @@ -95,7 +95,7 @@ struct FieldsView: View { } Text(.init(text)) .tint(Theme.Colors.accentXColor) - .foregroundStyle(Theme.Colors.textSecondaryLight) + .foregroundStyle(Theme.Colors.textSecondary) .font(Theme.Fonts.labelSmall) .padding(.vertical, 3) .id(UUID()) diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index 6c18775ce..3081fc784 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -219,7 +219,7 @@ public struct SignInView: View { ) Text(.init(text)) .tint(Theme.Colors.accentXColor) - .foregroundStyle(Theme.Colors.textSecondaryLight) + .foregroundStyle(Theme.Colors.textSecondary) .font(Theme.Fonts.labelSmall) .padding(.top, viewModel.socialAuthEnabled ? 0 : 15) .padding(.bottom, 15) diff --git a/Core/Core/View/Base/PickerMenu.swift b/Core/Core/View/Base/PickerMenu.swift index 2066a8aa0..9bacdbe65 100644 --- a/Core/Core/View/Base/PickerMenu.swift +++ b/Core/Core/View/Base/PickerMenu.swift @@ -88,6 +88,7 @@ public struct PickerMenu: View { .font(Theme.Fonts.bodySmall) .background(Theme.Colors.textInputStroke.cornerRadius(6)) .accessibilityIdentifier("picker_search_textfield") + .foregroundColor(Theme.Colors.textPrimary) Picker("", selection: $selectedItem) { ForEach(filteredItems, id: \.self) { item in Text(item.value) From 1a7564410e32c7c0b0b545901898d4b7ba969b18 Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Fri, 1 Mar 2024 15:23:30 +0500 Subject: [PATCH 042/136] chore: using custom color and font across the app for more elements --- .../Presentation/Base/FieldsView.swift | 2 +- .../Presentation/Login/SignInView.swift | 8 +++++--- .../Reset Password/ResetPasswordView.swift | 2 ++ .../Presentation/Startup/StartupView.swift | 4 +++- .../Extensions/UIApplicationExtension.swift | 1 + Core/Core/View/Base/AlertView.swift | 14 +++++++++++--- .../View/Base/AppReview/AppReviewView.swift | 2 ++ Core/Core/View/Base/CheckBoxView.swift | 1 + Core/Core/View/Base/PickerView.swift | 4 ++-- Core/Core/View/Base/RegistrationTextField.swift | 14 ++++++++------ .../ScrollSlidingTabBar.swift | 4 ++-- Core/Core/View/Base/UnitButtonView.swift | 4 ++-- .../DropdownList/CourseUnitDropDownTitle.swift | 6 ++++-- .../NativeDiscovery/DiscoveryView.swift | 1 + .../NativeDiscovery/SearchView.swift | 2 ++ .../Domain/Model/DiscussionPost.swift | 4 ++-- .../Comments/Base/CommentCell.swift | 10 ++++++---- .../Comments/Base/ParentCommentView.swift | 17 +++++++++++------ .../Comments/Responses/ResponsesView.swift | 12 +++++++----- .../Comments/Thread/ThreadView.swift | 12 +++++++----- .../CreateNewThread/CreateNewThreadView.swift | 16 ++++++++++------ .../DiscussionSearchTopicsView.swift | 1 + .../DiscussionTopics/DiscussionTopicsView.swift | 9 +++++++-- .../Presentation/Posts/PostsView.swift | 1 + .../EditProfile/EditProfileView.swift | 2 ++ .../Presentation/Profile/ProfileView.swift | 1 + .../Subviews/ProfileSupportInfoView.swift | 4 ++++ 27 files changed, 106 insertions(+), 52 deletions(-) diff --git a/Authorization/Authorization/Presentation/Base/FieldsView.swift b/Authorization/Authorization/Presentation/Base/FieldsView.swift index 0bc9f2ea2..d05eac80f 100644 --- a/Authorization/Authorization/Presentation/Base/FieldsView.swift +++ b/Authorization/Authorization/Presentation/Base/FieldsView.swift @@ -95,7 +95,7 @@ struct FieldsView: View { } Text(.init(text)) .tint(Theme.Colors.accentXColor) - .foregroundStyle(Theme.Colors.textSecondary) + .foregroundStyle(Theme.Colors.textSecondaryLight) .font(Theme.Fonts.labelSmall) .padding(.vertical, 3) .id(UUID()) diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index 3081fc784..828c89bb1 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -75,7 +75,8 @@ public struct SignInView: View { .foregroundColor(Theme.Colors.textPrimary) .accessibilityIdentifier("username_text") TextField(AuthLocalization.SignIn.emailOrUsername, text: $email) - .font(Theme.Fonts.bodyMedium) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textPrimary) .keyboardType(.emailAddress) .textContentType(.emailAddress) .autocapitalization(.none) @@ -98,7 +99,8 @@ public struct SignInView: View { .padding(.top, 18) .accessibilityIdentifier("password_text") SecureField(AuthLocalization.SignIn.password, text: $password) - .font(Theme.Fonts.bodyMedium) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textPrimary) .padding(.all, 14) .background( Theme.Shapes.textInputShape @@ -219,7 +221,7 @@ public struct SignInView: View { ) Text(.init(text)) .tint(Theme.Colors.accentXColor) - .foregroundStyle(Theme.Colors.textSecondary) + .foregroundStyle(Theme.Colors.textSecondaryLight) .font(Theme.Fonts.labelSmall) .padding(.top, viewModel.socialAuthEnabled ? 0 : 15) .padding(.bottom, 15) diff --git a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift index 3cec56f4a..6b375a75b 100644 --- a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift +++ b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift @@ -92,6 +92,8 @@ public struct ResetPasswordView: View { .foregroundColor(Theme.Colors.textPrimary) .accessibilityIdentifier("email_text") TextField(AuthLocalization.SignIn.email, text: $email) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textPrimary) .keyboardType(.emailAddress) .textContentType(.emailAddress) .autocapitalization(.none) diff --git a/Authorization/Authorization/Presentation/Startup/StartupView.swift b/Authorization/Authorization/Presentation/Startup/StartupView.swift index d5fdca04d..d68e97c62 100644 --- a/Authorization/Authorization/Presentation/Startup/StartupView.swift +++ b/Authorization/Authorization/Presentation/Startup/StartupView.swift @@ -55,6 +55,7 @@ public struct StartupView: View { Image(systemName: "magnifyingglass") .padding(.leading, 16) .padding(.top, 1) + .foregroundColor(Theme.Colors.textPrimary) TextField(AuthLocalization.Startup.searchPlaceholder, text: $searchQuery, onCommit: { if searchQuery.isEmpty { return } viewModel.router.showDiscoveryScreen( @@ -66,7 +67,8 @@ public struct StartupView: View { .autocorrectionDisabled() .frame(minHeight: 50) .submitLabel(.search) - .font(Theme.Fonts.bodyMedium) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textPrimary) .accessibilityIdentifier("explore_courses_textfield") }.overlay( diff --git a/Core/Core/Extensions/UIApplicationExtension.swift b/Core/Core/Extensions/UIApplicationExtension.swift index cb3dc37ca..39dfa7692 100644 --- a/Core/Core/Extensions/UIApplicationExtension.swift +++ b/Core/Core/Extensions/UIApplicationExtension.swift @@ -59,6 +59,7 @@ extension UINavigationController { .font: Theme.UIFonts.labelLarge() ], for: .normal) + UISegmentedControl.appearance().selectedSegmentTintColor = UIColor(Theme.Colors.accentColor) UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = Theme.UIColors.accentColor } diff --git a/Core/Core/View/Base/AlertView.swift b/Core/Core/View/Base/AlertView.swift index 80aa89307..021cd6dac 100644 --- a/Core/Core/View/Base/AlertView.swift +++ b/Core/Core/View/Base/AlertView.swift @@ -134,12 +134,14 @@ public struct AlertView: View { case .logOut: HStack { Spacer(minLength: 100) - CoreAssets.logOut.swiftUIImage + CoreAssets.logOut.swiftUIImage.renderingMode(.template) .padding(.top, isHorizontal ? 20 : 54) + .foregroundColor(Theme.Colors.textPrimary) Spacer(minLength: 100) } Text(alertMessage) .font(Theme.Fonts.titleLarge) + .foregroundColor(Theme.Colors.textPrimary) .padding(.vertical, isHorizontal ? 6 : 40) .multilineTextAlignment(.center) .padding(.horizontal, 40) @@ -147,17 +149,21 @@ public struct AlertView: View { case .leaveProfile, .deleteVideo: VStack(spacing: 20) { if type == .deleteVideo { - CoreAssets.warning.swiftUIImage + CoreAssets.warning.swiftUIImage.renderingMode(.template) + .foregroundColor(Theme.Colors.textPrimary) .padding(.top, isHorizontal ? 20 : 54) } else { - CoreAssets.leaveProfile.swiftUIImage + CoreAssets.leaveProfile.swiftUIImage.renderingMode(.template) + .foregroundColor(Theme.Colors.textPrimary) .padding(.top, isHorizontal ? 20 : 54) } Text(alertTitle) .font(Theme.Fonts.titleLarge) + .foregroundColor(Theme.Colors.textPrimary) .padding(.horizontal, 40) Text(alertMessage) .font(Theme.Fonts.bodyMedium) + .foregroundColor(Theme.Colors.textPrimary) .multilineTextAlignment(.center) .padding(.horizontal, 40) @@ -174,10 +180,12 @@ public struct AlertView: View { } Text(alertTitle) .font(Theme.Fonts.titleLarge) + .foregroundColor(Theme.Colors.textPrimary) .padding(.horizontal, 40) .padding(.top, 10) Text(alertMessage) .font(Theme.Fonts.bodyMedium) + .foregroundColor(Theme.Colors.textPrimary) .multilineTextAlignment(.center) .padding(.horizontal, 40) .frame(maxWidth: 250) diff --git a/Core/Core/View/Base/AppReview/AppReviewView.swift b/Core/Core/View/Base/AppReview/AppReviewView.swift index e7fedcf1b..c22765245 100644 --- a/Core/Core/View/Base/AppReview/AppReviewView.swift +++ b/Core/Core/View/Base/AppReview/AppReviewView.swift @@ -68,6 +68,8 @@ public struct AppReviewView: View { case .feedback: TextEditor(text: $viewModel.feedback) + .font(Theme.Fonts.bodyMedium) + .foregroundColor(Theme.Colors.textPrimary) .padding(.horizontal, 12) .padding(.vertical, 4) .hideScrollContentBackground() diff --git a/Core/Core/View/Base/CheckBoxView.swift b/Core/Core/View/Base/CheckBoxView.swift index 29253290a..a900d292a 100644 --- a/Core/Core/View/Base/CheckBoxView.swift +++ b/Core/Core/View/Base/CheckBoxView.swift @@ -30,6 +30,7 @@ public struct CheckBoxView: View { ) Text(text) .font(font) + .foregroundColor(Theme.Colors.textPrimary) } .onTapGesture { withAnimation(.linear(duration: 0.1)) { diff --git a/Core/Core/View/Base/PickerView.swift b/Core/Core/View/Base/PickerView.swift index c7437ac71..91be4684b 100644 --- a/Core/Core/View/Base/PickerView.swift +++ b/Core/Core/View/Base/PickerView.swift @@ -61,7 +61,7 @@ public struct PickerView: View { .stroke(lineWidth: 1) .fill(config.error == "" ? Theme.Colors.textInputStroke - : Color.red) + : Theme.Colors.alert) ) .shake($config.shake) Text(config.error == "" ? config.field.instructions @@ -69,7 +69,7 @@ public struct PickerView: View { .font(Theme.Fonts.labelMedium) .foregroundColor(config.error == "" ? Theme.Colors.textPrimary - : Color.red) + : Theme.Colors.alert) .accessibilityIdentifier("\(config.field.name)_instructions_text") } } diff --git a/Core/Core/View/Base/RegistrationTextField.swift b/Core/Core/View/Base/RegistrationTextField.swift index b2e8ffcb0..a6751451b 100644 --- a/Core/Core/View/Base/RegistrationTextField.swift +++ b/Core/Core/View/Base/RegistrationTextField.swift @@ -43,6 +43,7 @@ public struct RegistrationTextField: View { if isTextArea { TextEditor(text: $config.text) .font(Theme.Fonts.bodyMedium) + .foregroundColor(Theme.Colors.textPrimary) .padding(.horizontal, 12) .padding(.vertical, 4) .frame(height: 100) @@ -58,7 +59,7 @@ public struct RegistrationTextField: View { .fill( config.error == "" ? Theme.Colors.textInputStroke - : Color.red + : Theme.Colors.alert ) ) .shake($config.shake) @@ -81,14 +82,15 @@ public struct RegistrationTextField: View { .fill( config.error == "" ? Theme.Colors.textInputStroke - : Color.red + : Theme.Colors.alert ) ) .shake($config.shake) .accessibilityIdentifier("\(config.field.name)_textfield") } else { TextField(placeholder, text: $config.text) - .font(Theme.Fonts.bodyMedium) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textPrimary) .keyboardType(keyboardType) .textContentType(textContentType) .autocapitalization(.none) @@ -104,7 +106,7 @@ public struct RegistrationTextField: View { .fill( config.error == "" ? Theme.Colors.textInputStroke - : Color.red + : Theme.Colors.alert ) ) .shake($config.shake) @@ -115,8 +117,8 @@ public struct RegistrationTextField: View { Text(config.error == "" ? config.field.instructions : config.error) .font(Theme.Fonts.bodySmall) .foregroundColor(config.error == "" - ? Theme.Colors.textSecondary - : Color.red) + ? Theme.Colors.textSecondaryLight + : Theme.Colors.alert) .accessibilityIdentifier("\(config.field.name)_instructions_text") } } diff --git a/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift index 1408b5248..29401776f 100644 --- a/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift +++ b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift @@ -160,8 +160,8 @@ extension ScrollSlidingTabBar { } public static let `default` = Style( - font: Theme.Fonts.bodyMedium, - selectedFont: Theme.Fonts.titleSmall, + font: Theme.Fonts.bodyLarge, + selectedFont: Theme.Fonts.titleMedium, activeAccentColor: Theme.Colors.accentColor, inactiveAccentColor: Theme.Colors.textSecondary, indicatorHeight: 2, diff --git a/Core/Core/View/Base/UnitButtonView.swift b/Core/Core/View/Base/UnitButtonView.swift index 5dfc96775..6e39ff997 100644 --- a/Core/Core/View/Base/UnitButtonView.swift +++ b/Core/Core/View/Base/UnitButtonView.swift @@ -109,9 +109,9 @@ public struct UnitButtonView: View { } else { CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) .padding(.leading, 20) - .foregroundColor(Theme.Colors.accentColor) + .foregroundColor(Theme.Colors.secondaryButtonTextColor) Text(type.stringValue()) - .foregroundColor(Theme.Colors.accentColor) + .foregroundColor(Theme.Colors.secondaryButtonTextColor) .font(Theme.Fonts.labelLarge) .padding(.trailing, 20) } diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownTitle.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownTitle.swift index 13c31d3a4..93a666bcc 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownTitle.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownTitle.swift @@ -24,10 +24,12 @@ struct CourseUnitDropDownTitle: View { Text(title) .opacity(showDropdown ? 0.7 : 1.0) .lineLimit(1) - .font(Theme.Fonts.bodySmall) + .font(Theme.Fonts.bodyMedium) + .foregroundColor(Theme.Colors.textPrimary) if isAvailable { - Image(systemName: "chevron.down") + Image(systemName: "chevron.down").renderingMode(.template) .dropdownArrowRotationAnimation(value: showDropdown) + .foregroundColor(Theme.Colors.textPrimary) } } } diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift index 461a705ea..315034288 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift @@ -54,6 +54,7 @@ public struct DiscoveryView: View { // MARK: - Search fake field HStack(spacing: 11) { Image(systemName: "magnifyingglass") + .foregroundColor(Theme.Colors.textSecondary) .padding(.leading, 16) .padding(.top, 1) Text(DiscoveryLocalization.search) diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift index 782956544..9d53567d1 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift @@ -36,6 +36,7 @@ public struct SearchView: View { HStack(spacing: 11) { Image(systemName: "magnifyingglass") + .foregroundColor(Theme.Colors.textPrimary) .padding(.leading, 16) .padding(.top, 1) .foregroundColor( @@ -58,6 +59,7 @@ public struct SearchView: View { self.focused = true } .foregroundColor(Theme.Colors.textPrimary) + .font(Theme.Fonts.bodyLarge) Spacer() if !viewModel.searchText.trimmingCharacters(in: .whitespaces).isEmpty { Button(action: { viewModel.searchText.removeAll() }, label: { diff --git a/Discussion/Discussion/Domain/Model/DiscussionPost.swift b/Discussion/Discussion/Domain/Model/DiscussionPost.swift index 398b0888f..e73bc686c 100644 --- a/Discussion/Discussion/Domain/Model/DiscussionPost.swift +++ b/Discussion/Discussion/Domain/Model/DiscussionPost.swift @@ -25,9 +25,9 @@ public enum PostType: String, Codable { public func getImage() -> Image { switch self { case .question: - return CoreAssets.question.swiftUIImage + return CoreAssets.question.swiftUIImage.renderingMode(.template) case .discussion: - return CoreAssets.discussion.swiftUIImage + return CoreAssets.discussion.swiftUIImage.renderingMode(.template) } } } diff --git a/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift b/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift index acfb41b5a..ff11baedd 100644 --- a/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift +++ b/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift @@ -98,8 +98,9 @@ public struct CommentCell: View { Text(url.absoluteString) .multilineTextAlignment(.leading) } - }.foregroundColor(Theme.Colors.accentColor) - .font(Theme.Fonts.bodyMedium) + } + .foregroundColor(Theme.Colors.accentColor) + .font(Theme.Fonts.bodyMedium) } } @@ -132,8 +133,9 @@ public struct CommentCell: View { Image(systemName: "message.fill") Text("\(comment.responsesCount)") Text(DiscussionLocalization.commentsCount(comment.responsesCount)) - }.foregroundColor(Theme.Colors.textSecondary) - .font(Theme.Fonts.labelLarge) + } + .foregroundColor(Theme.Colors.textSecondary) + .font(Theme.Fonts.labelLarge) } }.foregroundColor(Theme.Colors.accentColor) .font(Theme.Fonts.labelMedium) diff --git a/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift b/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift index 1168f91a5..821e8a342 100644 --- a/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift +++ b/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift @@ -53,10 +53,11 @@ public struct ParentCommentView: View { VStack(alignment: .leading) { Text(comments.authorName) .font(Theme.Fonts.titleMedium) + .foregroundColor(Theme.Colors.textPrimary) Text(comments.postDate .dateToString(style: .lastPost)) .font(Theme.Fonts.labelSmall) - .foregroundColor(Theme.Colors.textSecondary) + .foregroundColor(Theme.Colors.textSecondaryLight) } Spacer() if isThread { @@ -70,13 +71,15 @@ public struct ParentCommentView: View { .font(Theme.Fonts.bodyMedium) }).foregroundColor(comments.followed ? Theme.Colors.accentColor - : Theme.Colors.textSecondary) + : Theme.Colors.textSecondaryLight) } }.padding(.top, 31) Text(comments.postTitle) .font(Theme.Fonts.titleLarge) + .foregroundColor(Theme.Colors.textPrimary) Text(comments.postBodyHtml.hideHtmlTagsAndUrls()) .font(Theme.Fonts.bodyMedium) + .foregroundColor(Theme.Colors.textPrimary) .fixedSize(horizontal: false, vertical: true) .padding(.bottom, 8) ForEach(Array(comments.postBody.extractURLs().enumerated()), id: \.offset) { _, url in @@ -90,12 +93,12 @@ public struct ParentCommentView: View { } } else { HStack { - Image(systemName: "globe") + Image(systemName: "globe").renderingMode(.template) Link(destination: url) { Text(url.absoluteString) .multilineTextAlignment(.leading) } - }.foregroundColor(Theme.Colors.accentColor) + }.foregroundColor(Theme.Colors.accentXColor) .font(Theme.Fonts.bodyMedium) } } @@ -107,11 +110,13 @@ public struct ParentCommentView: View { ? CoreAssets.voted.swiftUIImage : CoreAssets.vote.swiftUIImage Text("\(comments.votesCount)") + .foregroundColor(Theme.Colors.textPrimary) Text(DiscussionLocalization.votesCount(comments.votesCount)) .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) }).foregroundColor(comments.voted ? Theme.Colors.accentColor - : Theme.Colors.textSecondary) + : Theme.Colors.textSecondaryLight) Spacer() Button(action: { onReportTap() @@ -126,7 +131,7 @@ public struct ParentCommentView: View { } .accentColor(comments.abuseFlagged ? Theme.Colors.alert - : Theme.Colors.textSecondary) + : Theme.Colors.textSecondaryLight) .font(Theme.Fonts.labelLarge) .padding(.top, 8) } diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index 9ccb56e34..34069c0b4 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -45,7 +45,7 @@ public struct ResponsesView: View { viewModel.comments = [] _ = await viewModel.getComments( commentID: commentID, - parentComment: parentComment, + parentComment: parentComment, page: 1 ) }) { @@ -87,10 +87,12 @@ public struct ResponsesView: View { Text("\(viewModel.itemsCount)") Text(DiscussionLocalization.commentsCount(viewModel.itemsCount)) Spacer() - }.padding(.top, 40) - .padding(.bottom, 14) - .padding(.leading, 24) - .font(Theme.Fonts.titleMedium) + } + .padding(.top, 40) + .padding(.bottom, 14) + .padding(.leading, 24) + .font(Theme.Fonts.titleMedium) + .foregroundColor(Theme.Colors.textPrimary) ForEach( Array(comments.comments.enumerated()), id: \.offset ) { index, comment in diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index 2249cf192..b004ec6ed 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -42,7 +42,7 @@ public struct ThreadView: View { if let comments = viewModel.postComments { ParentCommentView( comments: comments, - isThread: true, + isThread: true, onAvatarTap: { username in viewModel.router.showUserDetails(username: username) }, @@ -86,10 +86,12 @@ public struct ThreadView: View { Text("\(viewModel.itemsCount)") Text(DiscussionLocalization.responsesCount(viewModel.itemsCount)) Spacer() - }.padding(.top, 40) - .padding(.bottom, 14) - .padding(.leading, 24) - .font(Theme.Fonts.titleMedium) + } + .padding(.top, 40) + .padding(.bottom, 14) + .padding(.leading, 24) + .font(Theme.Fonts.titleMedium) + .foregroundColor(Theme.Colors.textPrimary) ForEach(Array(comments.comments.enumerated()), id: \.offset) { index, comment in CommentCell( diff --git a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift index 265494791..6fe53baea 100644 --- a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift +++ b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift @@ -37,8 +37,6 @@ public struct CreateNewThreadView: View { Task { await viewModel.getTopics(courseID: courseID) } - UISegmentedControl.appearance().selectedSegmentTintColor = UIColor(Theme.Colors.accentColor) - UISegmentedControl.appearance().setTitleTextAttributes([.foregroundColor: Theme.Colors.white.uiColor()], for: .selected) } public var body: some View { @@ -73,6 +71,7 @@ public struct CreateNewThreadView: View { // MARK: Topic picker Group { Text(DiscussionLocalization.CreateThread.topic) + .foregroundColor(Theme.Colors.textPrimary) .font(Theme.Fonts.titleSmall) .padding(.top, 16) @@ -89,6 +88,7 @@ public struct CreateNewThreadView: View { Text(viewModel.allTopics.first(where: { $0.id == viewModel.selectedTopic })?.name ?? "") .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) .frame(height: 40, alignment: .leading) Spacer() Image(systemName: "chevron.down") @@ -109,10 +109,12 @@ public struct CreateNewThreadView: View { Group { Text(DiscussionLocalization.CreateThread.title) .font(Theme.Fonts.titleSmall) - + Text(" *").foregroundColor(.red) + .foregroundColor(Theme.Colors.textPrimary) + + Text(" *").foregroundColor(Theme.Colors.alert) }.padding(.top, 16) TextField("", text: $postTitle) - .font(Theme.Fonts.labelLarge) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textPrimary) .padding(14) .frame(height: 40) .background( @@ -130,10 +132,12 @@ public struct CreateNewThreadView: View { Group { Text("\(postType.localizedValue.capitalized)") .font(Theme.Fonts.titleSmall) - + Text(" *").foregroundColor(.red) + .foregroundColor(Theme.Colors.textPrimary) + + Text(" *").foregroundColor(Theme.Colors.alert) }.padding(.top, 16) TextEditor(text: $postBody) - .font(Theme.Fonts.labelLarge) + .font(Theme.Fonts.bodyMedium) + .foregroundColor(Theme.Colors.textPrimary) .padding(.horizontal, 10) .padding(.vertical, 10) .frame(height: 200) diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift index 637f057be..6deb26098 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift @@ -31,6 +31,7 @@ public struct DiscussionSearchTopicsView: View { .padding(.bottom, -7) HStack(spacing: 11) { Image(systemName: "magnifyingglass") + .foregroundColor(Theme.Colors.textPrimary) .padding(.leading, 16) .padding(.top, -1) .foregroundColor( diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift index dcff23388..c5d84f55c 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift @@ -28,6 +28,7 @@ public struct DiscussionTopicsView: View { // MARK: - Search fake field HStack(spacing: 11) { Image(systemName: "magnifyingglass") + .foregroundColor(Theme.Colors.textSecondary) .padding(.leading, 16) .padding(.top, 1) Text(DiscussionLocalization.Topics.search) @@ -78,9 +79,11 @@ public struct DiscussionTopicsView: View { }, label: { VStack { Spacer(minLength: 0) - CoreAssets.allPosts.swiftUIImage + CoreAssets.allPosts.swiftUIImage.renderingMode(.template) + .foregroundColor(Theme.Colors.textPrimary) Text(allTopics.name) .font(Theme.Fonts.titleSmall) + .foregroundColor(Theme.Colors.textPrimary) Spacer(minLength: 0) } .frame(maxWidth: .infinity) @@ -94,9 +97,11 @@ public struct DiscussionTopicsView: View { }, label: { VStack(alignment: .center) { Spacer(minLength: 0) - CoreAssets.followed.swiftUIImage + CoreAssets.followed.swiftUIImage.renderingMode(.template) + .foregroundColor(Theme.Colors.textPrimary) Text(followed.name) .font(Theme.Fonts.titleSmall) + .foregroundColor(Theme.Colors.textPrimary) Spacer(minLength: 0) } .frame(maxWidth: .infinity) diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index ab24def7c..5b3da4cfb 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -293,6 +293,7 @@ public struct PostCell: View { VStack(alignment: .leading, spacing: 8) { HStack(spacing: 4) { post.type.getImage() + .foregroundColor(Theme.Colors.accentColor) Text(post.type.localizedValue.capitalized) Spacer() if post.unreadCommentCount - 1 > 0 { diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift index ffeecd11b..313da0f2f 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift @@ -80,6 +80,8 @@ public struct EditProfileView: View { Text(ProfileLocalization.Edit.Fields.aboutMe) .font(Theme.Fonts.titleMedium) TextEditor(text: $viewModel.profileChanges.shortBiography) + .font(Theme.Fonts.bodyMedium) + .foregroundColor(Theme.Colors.textPrimary) .padding(.horizontal, 12) .padding(.vertical, 4) .frame(height: 200) diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index 6354d5999..5142f205d 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -106,6 +106,7 @@ public struct ProfileView: View { .padding(.top, 30) Text(viewModel.userModel?.name ?? "") .font(Theme.Fonts.headlineSmall) + .foregroundColor(Theme.Colors.textPrimary) .padding(.top, 20) Text("@\(viewModel.userModel?.username ?? "")") .font(Theme.Fonts.labelLarge) diff --git a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift index aa04f1d21..6a053abe5 100644 --- a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift +++ b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift @@ -105,6 +105,8 @@ struct ProfileSupportInfoView: View { HStack { Text(viewModel.title) .multilineTextAlignment(.leading) + .font(Theme.Fonts.bodyMedium) + .foregroundColor(Theme.Colors.textPrimary) Spacer() Image(systemName: "chevron.right") } @@ -133,6 +135,8 @@ struct ProfileSupportInfoView: View { } label: { HStack { Text(linkViewModel.title) + .foregroundColor(Theme.Colors.textPrimary) + .font(Theme.Fonts.bodyMedium) Spacer() Image(systemName: "chevron.right") } From be22a55418cbe98d84e1c9e379469a1cdf6a80a2 Mon Sep 17 00:00:00 2001 From: Shafqat Muneer Date: Fri, 1 Mar 2024 16:09:11 +0500 Subject: [PATCH 043/136] feat: Ability to shift courses dates if deadlines have been missed (#288) --- Core/Core/Extensions/DateExtension.swift | 4 +- Core/Core/Extensions/Notification.swift | 1 + Core/Core/SwiftGen/Strings.swift | 26 +- Core/Core/en.lproj/Localizable.strings | 14 +- Core/Core/uk.lproj/Localizable.strings | 14 +- Course/Course.xcodeproj/project.pbxproj | 40 +- Course/Course/Data/CourseRepository.swift | 1015 +---------------- .../Course/Data/Model/Data_CourseDates.swift | 109 +- .../Course/Data/Network/CourseEndpoint.swift | 14 + Course/Course/Domain/CourseInteractor.swift | 10 + Course/Course/Domain/Model/CourseDates.swift | 6 + .../Container/CourseContainerView.swift | 21 +- .../Container/CourseContainerViewModel.swift | 126 +- .../Presentation/Dates/CourseDatesView.swift | 31 +- .../Dates/CourseDatesViewModel.swift | 48 + .../Dates/DatesStatusInfoView.swift | 82 ++ .../Outline/CourseOutlineView.swift | 62 +- Course/Course/SwiftGen/Strings.swift | 62 + .../Views/DatesShiftedSuccessView.swift | 113 ++ Course/Course/en.lproj/Localizable.strings | 31 + Course/Course/uk.lproj/Localizable.strings | 31 + Course/CourseTests/CourseMock.generated.swift | 76 ++ .../Unit/CourseDateViewModelTests.swift | 11 +- Dashboard/Dashboard.xcodeproj/project.pbxproj | 12 + .../Dashboard/Data/DashboardRepository.swift | 12 + .../Data/Mock/CourseEnrollmentsMock.swift | 612 ++++++++++ Theme/Theme/SwiftGen/ThemeAssets.swift | 2 +- 27 files changed, 1483 insertions(+), 1102 deletions(-) create mode 100644 Course/Course/Presentation/Dates/DatesStatusInfoView.swift create mode 100644 Course/Course/Views/DatesShiftedSuccessView.swift create mode 100644 Dashboard/Dashboard/Data/Mock/CourseEnrollmentsMock.swift diff --git a/Core/Core/Extensions/DateExtension.swift b/Core/Core/Extensions/DateExtension.swift index 36f93d5ee..2915fdc18 100644 --- a/Core/Core/Extensions/DateExtension.swift +++ b/Core/Core/Extensions/DateExtension.swift @@ -172,9 +172,9 @@ public extension Date { case 2...6: return timeAgoDisplay() case -1: - return CoreLocalization.CourseDates.tomorrow + return CoreLocalization.tomorrow case 1: - return CoreLocalization.CourseDates.yesterday + return CoreLocalization.yesterday default: if day > 6 || day < -6 { return dateFormatterString diff --git a/Core/Core/Extensions/Notification.swift b/Core/Core/Extensions/Notification.swift index 4f47b478a..c4d3d70bb 100644 --- a/Core/Core/Extensions/Notification.swift +++ b/Core/Core/Extensions/Notification.swift @@ -15,4 +15,5 @@ public extension Notification.Name { static let onNewVersionAvaliable = Notification.Name("onNewVersionAvaliable") static let webviewReloadNotification = Notification.Name("webviewReloadNotification") static let onBlockCompletion = Notification.Name.init("onBlockCompletion") + static let shiftCourseDates = Notification.Name("shiftCourseDates") } diff --git a/Core/Core/SwiftGen/Strings.swift b/Core/Core/SwiftGen/Strings.swift index 8a4236511..3e8883c3b 100644 --- a/Core/Core/SwiftGen/Strings.swift +++ b/Core/Core/SwiftGen/Strings.swift @@ -14,6 +14,10 @@ public enum CoreLocalization { public static let done = CoreLocalization.tr("Localizable", "DONE", fallback: "Done") /// The user canceled the sign-in flow. public static let socialSignCanceled = CoreLocalization.tr("Localizable", "SOCIAL_SIGN_CANCELED", fallback: "The user canceled the sign-in flow.") + /// Tomorrow + public static let tomorrow = CoreLocalization.tr("Localizable", "TOMORROW", fallback: "Tomorrow") + /// Yesterday + public static let yesterday = CoreLocalization.tr("Localizable", "YESTERDAY", fallback: "Yesterday") public enum Alert { /// ACCEPT public static let accept = CoreLocalization.tr("Localizable", "ALERT.ACCEPT", fallback: "ACCEPT") @@ -60,28 +64,6 @@ 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") - /// Item Hidden - public static let itemHidden = CoreLocalization.tr("Localizable", "COURSE_DATES.ITEM_HIDDEN", fallback: "Item Hidden") - /// Items Hidden - public static let itemsHidden = CoreLocalization.tr("Localizable", "COURSE_DATES.ITEMS_HIDDEN", fallback: "Items Hidden") - /// 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") - /// Tomorrow - public static let tomorrow = CoreLocalization.tr("Localizable", "COURSE_DATES.TOMORROW", fallback: "Tomorrow") - /// 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") - /// Yesterday - public static let yesterday = CoreLocalization.tr("Localizable", "COURSE_DATES.YESTERDAY", fallback: "Yesterday") - } 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 2c982c851..7bfaab4e1 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -98,18 +98,10 @@ "REVIEW.BUTTON.RATE_US" = "Rate Us"; "REVIEW.EMAIL.TITLE" = "Select email client:"; -"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"; -"COURSE_DATES.TOMORROW" = "Tomorrow"; -"COURSE_DATES.YESTERDAY" = "Yesterday"; -"COURSE_DATES.ITEMS_HIDDEN" = "Items Hidden"; -"COURSE_DATES.ITEM_HIDDEN" = "Item Hidden"; - "SOCIAL_SIGN_CANCELED" = "The user canceled the sign-in flow."; "SIGN_IN.LOG_IN_BTN" = "Sign in"; "SIGN_IN.REGISTER_BTN" = "Register"; + +"TOMORROW" = "Tomorrow"; +"YESTERDAY" = "Yesterday"; diff --git a/Core/Core/uk.lproj/Localizable.strings b/Core/Core/uk.lproj/Localizable.strings index 1f0ecb198..7847ee12c 100644 --- a/Core/Core/uk.lproj/Localizable.strings +++ b/Core/Core/uk.lproj/Localizable.strings @@ -97,19 +97,11 @@ "REVIEW.BUTTON.RATE_US" = "Оцінити нас"; "REVIEW.EMAIL.TITLE" = "Виберіть поштового клієнта:"; -"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"; -"COURSE_DATES.TOMORROW" = "Tomorrow"; -"COURSE_DATES.YESTERDAY" = "Yesterday"; -"COURSE_DATES.ITEMS_HIDDEN" = "Items Hidden"; -"COURSE_DATES.ITEM_HIDDEN" = "Item Hidden"; - "SOCIAL_SIGN_CANCELED" = "The user canceled the sign-in flow."; "AUTHORIZATION_FAILED" = "Authorization failed."; "SIGN_IN.LOG_IN_BTN" = "Увійти"; "SIGN_IN.REGISTER_BTN" = "Реєстрація"; + +"TOMORROW" = "Tomorrow"; +"YESTERDAY" = "Yesterday"; diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index 8d28ee87e..9de40f83f 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -67,8 +67,9 @@ 197FB8EA8F92F00A8F383D82 /* Pods_App_Course.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E5E795BD160CDA7D9C120DE6 /* Pods_App_Course.framework */; }; 975F475E2B6151FD00E5B031 /* CourseDatesMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 975F475D2B6151FD00E5B031 /* CourseDatesMock.swift */; }; 975F47602B615DA700E5B031 /* CourseStructureMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 975F475F2B615DA700E5B031 /* CourseStructureMock.swift */; }; + 97CA95252B875EE200A9EDEA /* DatesShiftedSuccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97CA95242B875EE200A9EDEA /* DatesShiftedSuccessView.swift */; }; + 97E7DF0F2B7C852A00A2A09B /* DatesStatusInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97E7DF0E2B7C852A00A2A09B /* DatesStatusInfoView.swift */; }; B8F50317B6B830A0E520C954 /* Pods_App_Course_CourseTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50E59D2B81E12610964282C5 /* Pods_App_Course_CourseTests.framework */; }; - BAD9CA2D2B2736BB00DE790A /* LessonLineProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA2C2B2736BB00DE790A /* LessonLineProgressView.swift */; }; BA58CF5D2B3D804D005B102E /* CourseStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA58CF5C2B3D804D005B102E /* CourseStorage.swift */; }; BA58CF612B471041005B102E /* VideoDownloadQualityBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA58CF602B471041005B102E /* VideoDownloadQualityBarView.swift */; }; BA58CF642B471363005B102E /* VideoDownloadQualityContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA58CF632B471363005B102E /* VideoDownloadQualityContainerView.swift */; }; @@ -76,6 +77,7 @@ BAC0E0D82B32EF03006B68A9 /* DownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC0E0D72B32EF03006B68A9 /* DownloadsView.swift */; }; BAC0E0DB2B32F0AE006B68A9 /* CourseVideoDownloadBarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC0E0DA2B32F0AE006B68A9 /* CourseVideoDownloadBarViewModel.swift */; }; BAC0E0DE2B32F0F3006B68A9 /* DownloadsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC0E0DD2B32F0F3006B68A9 /* DownloadsViewModel.swift */; }; + BAD9CA2D2B2736BB00DE790A /* LessonLineProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA2C2B2736BB00DE790A /* LessonLineProgressView.swift */; }; BAD9CA442B2C87A200DE790A /* CourseStructureView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA432B2C87A200DE790A /* CourseStructureView.swift */; }; BAD9CA4A2B2C88E000DE790A /* CourseVideoDownloadBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA492B2C88E000DE790A /* CourseVideoDownloadBarView.swift */; }; DB205BFB2AE81B1200136EC2 /* CourseDateViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB205BFA2AE81B1200136EC2 /* CourseDateViewModelTests.swift */; }; @@ -169,12 +171,13 @@ 831B08139890634968EDD44D /* Pods-App-Course-CourseTests.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Course-CourseTests.debugstage.xcconfig"; path = "Target Support Files/Pods-App-Course-CourseTests/Pods-App-Course-CourseTests.debugstage.xcconfig"; sourceTree = ""; }; 975F475D2B6151FD00E5B031 /* CourseDatesMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDatesMock.swift; sourceTree = ""; }; 975F475F2B615DA700E5B031 /* CourseStructureMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseStructureMock.swift; sourceTree = ""; }; + 97CA95242B875EE200A9EDEA /* DatesShiftedSuccessView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatesShiftedSuccessView.swift; sourceTree = ""; }; + 97E7DF0E2B7C852A00A2A09B /* DatesStatusInfoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatesStatusInfoView.swift; sourceTree = ""; }; 99AEF08FD75F1509863D3302 /* Pods-App-CourseDetails.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-CourseDetails.debugprod.xcconfig"; path = "Target Support Files/Pods-App-CourseDetails/Pods-App-CourseDetails.debugprod.xcconfig"; sourceTree = ""; }; 9B5D3D31A9CFA08B6C4347BD /* Pods-App-CourseDetails.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-CourseDetails.releasedev.xcconfig"; path = "Target Support Files/Pods-App-CourseDetails/Pods-App-CourseDetails.releasedev.xcconfig"; sourceTree = ""; }; 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 = ""; }; - BAD9CA2C2B2736BB00DE790A /* LessonLineProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LessonLineProgressView.swift; sourceTree = ""; }; BA58CF5C2B3D804D005B102E /* CourseStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseStorage.swift; sourceTree = ""; }; BA58CF602B471041005B102E /* VideoDownloadQualityBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadQualityBarView.swift; sourceTree = ""; }; BA58CF632B471363005B102E /* VideoDownloadQualityContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadQualityContainerView.swift; sourceTree = ""; }; @@ -182,6 +185,7 @@ BAC0E0D72B32EF03006B68A9 /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = ""; }; BAC0E0DA2B32F0AE006B68A9 /* CourseVideoDownloadBarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseVideoDownloadBarViewModel.swift; sourceTree = ""; }; BAC0E0DD2B32F0F3006B68A9 /* DownloadsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsViewModel.swift; sourceTree = ""; }; + BAD9CA2C2B2736BB00DE790A /* LessonLineProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LessonLineProgressView.swift; sourceTree = ""; }; BAD9CA432B2C87A200DE790A /* CourseStructureView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseStructureView.swift; sourceTree = ""; }; BAD9CA492B2C88E000DE790A /* CourseVideoDownloadBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseVideoDownloadBarView.swift; sourceTree = ""; }; DB205BFA2AE81B1200136EC2 /* CourseDateViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDateViewModelTests.swift; sourceTree = ""; }; @@ -284,6 +288,7 @@ 02B6B3B828E1D12900232911 /* Data */, 02B6B3B528E1D10700232911 /* Domain */, 02EAE2CA28E1F0A700529644 /* Presentation */, + 97CA95212B875EA200A9EDEA /* Views */, 02B6B3B428E1C49400232911 /* Localizable.strings */, ); path = Course; @@ -474,6 +479,23 @@ path = Unit; sourceTree = ""; }; + 975F475C2B61517A00E5B031 /* Mock */ = { + isa = PBXGroup; + children = ( + 975F475D2B6151FD00E5B031 /* CourseDatesMock.swift */, + 975F475F2B615DA700E5B031 /* CourseStructureMock.swift */, + ); + path = Mock; + sourceTree = ""; + }; + 97CA95212B875EA200A9EDEA /* Views */ = { + isa = PBXGroup; + children = ( + 97CA95242B875EE200A9EDEA /* DatesShiftedSuccessView.swift */, + ); + path = Views; + sourceTree = ""; + }; BA58CF622B471047005B102E /* VideoDownloadQualityBarView */ = { isa = PBXGroup; children = ( @@ -527,15 +549,6 @@ BA58CF622B471047005B102E /* VideoDownloadQualityBarView */, ); path = Subviews; - sourceTree = ""; - }; - 975F475C2B61517A00E5B031 /* Mock */ = { - isa = PBXGroup; - children = ( - 975F475D2B6151FD00E5B031 /* CourseDatesMock.swift */, - 975F475F2B615DA700E5B031 /* CourseStructureMock.swift */, - ); - path = Mock; sourceTree = ""; }; D52670044E8768425E23C627 /* Pods */ = { @@ -572,6 +585,7 @@ isa = PBXGroup; children = ( DB7D6EAB2ADFCAC40036BB13 /* CourseDatesView.swift */, + 97E7DF0E2B7C852A00A2A09B /* DatesStatusInfoView.swift */, DB7D6EAD2ADFCB4A0036BB13 /* CourseDatesViewModel.swift */, ); path = Dates; @@ -826,11 +840,11 @@ 02F0144F28F46474002E513D /* CourseContainerView.swift in Sources */, BAAD62C82AFD00EE000E6103 /* CourseStructureNestedListView.swift in Sources */, 02A8076829474831007F53AB /* CourseVerticalView.swift in Sources */, + 97E7DF0F2B7C852A00A2A09B /* DatesStatusInfoView.swift in Sources */, 0231124D28EDA804002588FB /* CourseUnitView.swift in Sources */, 027020FC28E7362100F54332 /* Data_CourseOutlineResponse.swift in Sources */, DB7D6EB02ADFDA0E0036BB13 /* CourseDates.swift in Sources */, BAD9CA2D2B2736BB00DE790A /* LessonLineProgressView.swift in Sources */, - 02E685C028E4B629000AE015 /* CourseDetailsViewModel.swift in Sources */, 060E8BCA2B5FD68C0080C952 /* UnitStack.swift in Sources */, 0295C889299BBE8200ABE571 /* CourseNavigationView.swift in Sources */, 06FD7EDF2B1F29F3008D632B /* CourseVerticalImageView.swift in Sources */, @@ -846,8 +860,8 @@ 022C64D829ACEC48000F532B /* HandoutsView.swift in Sources */, 02454CA82A2619890043052A /* DiscussionView.swift in Sources */, 0265B4B728E2141D00E6EAFD /* Strings.swift in Sources */, - 02B6B3C128E1DBA100232911 /* Data_CourseDetailsResponse.swift in Sources */, BAC0E0D82B32EF03006B68A9 /* DownloadsView.swift in Sources */, + 97CA95252B875EE200A9EDEA /* DatesShiftedSuccessView.swift in Sources */, DB7D6EAC2ADFCAC50036BB13 /* CourseDatesView.swift in Sources */, 0766DFCC299AA7A600EBEF6A /* YouTubeVideoPlayer.swift in Sources */, 022F8E162A1DFBC6008EFAB9 /* YouTubeVideoPlayerViewModel.swift in Sources */, diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index 68765c1c5..1598a05df 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -18,6 +18,8 @@ public protocol CourseRepositoryProtocol { func getSubtitles(url: String, selectedLanguage: String) async throws -> String func getCourseDates(courseID: String) async throws -> CourseDates func getCourseDatesOffline(courseID: String) async throws -> CourseDates + func getCourseDeadlineInfo(courseID: String) async throws -> CourseDateBanner + func shiftDueDates(courseID: String) async throws } public class CourseRepository: CourseRepositoryProtocol { @@ -61,6 +63,10 @@ public class CourseRepository: CourseRepositoryProtocol { ) } + public func shiftDueDates(courseID: String) async throws { + try await api.requestData(CourseEndpoint.courseDatesReset(courseID: courseID)) + } + public func getHandouts(courseID: String) async throws -> String? { return try await api.requestData(CourseEndpoint.getHandouts(courseID: courseID)) .mapResponse(DataLayer.HandoutsResponse.self) @@ -100,6 +106,13 @@ public class CourseRepository: CourseRepositoryProtocol { return courseDates } + public func getCourseDeadlineInfo(courseID: String) async throws -> CourseDateBanner { + let courseDateBanner = try await api.requestData( + CourseEndpoint.getCourseDeadlineInfo(courseID: courseID) + ).mapResponse(DataLayer.CourseDateBanner.self).domain + return courseDateBanner + } + public func getCourseDatesOffline(courseID: String) async throws -> CourseDates { return try persistence.loadCourseDates(courseID: courseID) } @@ -255,6 +268,15 @@ class CourseRepositoryMock: CourseRepositoryProtocol { } } + func getCourseDeadlineInfo(courseID: String) async throws -> CourseDateBanner { + let courseDates = try + await getCourseDates(courseID: courseID) + return CourseDateBanner( + datesBannerInfo: courseDates.datesBannerInfo, + hasEnded: courseDates.hasEnded + ) + } + func getLoadedCourseBlocks(courseID: String) throws -> CourseStructure { let decoder = JSONDecoder() let jsonData = Data(CourseRepository.courseStructureJson.utf8) @@ -276,6 +298,10 @@ class CourseRepositoryMock: CourseRepositoryProtocol { } + func shiftDueDates(courseID: String) async throws { + + } + public func getSubtitles(url: String, selectedLanguage: String) async throws -> String { return """ 0 @@ -417,995 +443,6 @@ And there are various ways of describing it-- call it oral poetry or streamPriority: encodedVideo.streamPriority ) } - - private let courseStructureJson: String = """ - {"root": "block-v1:QA+comparison+2022+type@course+block@course", - "blocks": { - "block-v1:QA+comparison+2022+type@comparison+block@be1704c576284ba39753c6f0ea4a4c78": { - "id": "block-v1:QA+comparison+2022+type@comparison+block@be1704c576284ba39753c6f0ea4a4c78", - "block_id": "be1704c576284ba39753c6f0ea4a4c78", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@be1704c576284ba39753c6f0ea4a4c78", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@be1704c576284ba39753c6f0ea4a4c78?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@comparison+block@be1704c576284ba39753c6f0ea4a4c78", - "type": "comparison", - "display_name": "Comparison", - "graded": false, - "student_view_multi_device": false, - "block_counts": { - "video": 0 - }, - "descendants": [ - - ], - "completion": 0.0 - }, - "block-v1:QA+comparison+2022+type@problem+block@93acc543871e4c73bc20a72a64e93296": { - "id": "block-v1:QA+comparison+2022+type@problem+block@93acc543871e4c73bc20a72a64e93296", - "block_id": "93acc543871e4c73bc20a72a64e93296", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@93acc543871e4c73bc20a72a64e93296", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@93acc543871e4c73bc20a72a64e93296?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@problem+block@93acc543871e4c73bc20a72a64e93296", - "type": "problem", - "display_name": "Dropdown with Hints and Feedback", - "graded": false, - "student_view_multi_device": true, - "block_counts": { - "video": 0 - }, - "descendants": [ - - ], - "completion": 0.0 - }, - "block-v1:QA+comparison+2022+type@comparison+block@06c17035106e48328ebcd042babcf47b": { - "id": "block-v1:QA+comparison+2022+type@comparison+block@06c17035106e48328ebcd042babcf47b", - "block_id": "06c17035106e48328ebcd042babcf47b", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@06c17035106e48328ebcd042babcf47b", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@06c17035106e48328ebcd042babcf47b?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@comparison+block@06c17035106e48328ebcd042babcf47b", - "type": "comparison", - "display_name": "Comparison", - "graded": false, - "student_view_multi_device": false, - "block_counts": { - "video": 0 - }, - "descendants": [ - - ], - "completion": 0.0 - }, - "block-v1:QA+comparison+2022+type@problem+block@c19e41b61db14efe9c45f1354332ae58": { - "id": "block-v1:QA+comparison+2022+type@problem+block@c19e41b61db14efe9c45f1354332ae58", - "block_id": "c19e41b61db14efe9c45f1354332ae58", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@c19e41b61db14efe9c45f1354332ae58", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@c19e41b61db14efe9c45f1354332ae58?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@problem+block@c19e41b61db14efe9c45f1354332ae58", - "type": "problem", - "display_name": "Text Input with Hints and Feedback", - "graded": false, - "student_view_multi_device": true, - "block_counts": { - "video": 0 - }, - "descendants": [ - - ], - "completion": 0.0 - }, - "block-v1:QA+comparison+2022+type@problem+block@0d96732f577b4ff68799faf8235d1bfb": { - "id": "block-v1:QA+comparison+2022+type@problem+block@0d96732f577b4ff68799faf8235d1bfb", - "block_id": "0d96732f577b4ff68799faf8235d1bfb", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@0d96732f577b4ff68799faf8235d1bfb", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@0d96732f577b4ff68799faf8235d1bfb?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@problem+block@0d96732f577b4ff68799faf8235d1bfb", - "type": "problem", - "display_name": "Numerical Input with Hints and Feedback", - "graded": false, - "student_view_multi_device": true, - "block_counts": { - "video": 0 - }, - "descendants": [ - - ], - "completion": 0.0 - }, - "block-v1:QA+comparison+2022+type@problem+block@dd2e22fdf0724bd88c8b2e6b68dedd96": { - "id": "block-v1:QA+comparison+2022+type@problem+block@dd2e22fdf0724bd88c8b2e6b68dedd96", - "block_id": "dd2e22fdf0724bd88c8b2e6b68dedd96", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@dd2e22fdf0724bd88c8b2e6b68dedd96", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@dd2e22fdf0724bd88c8b2e6b68dedd96?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@problem+block@dd2e22fdf0724bd88c8b2e6b68dedd96", - "type": "problem", - "display_name": "Blank Common Problem", - "graded": false, - "student_view_multi_device": true, - "block_counts": { - "video": 0 - }, - "descendants": [ - - ], - "completion": 0.0 - }, - "block-v1:QA+comparison+2022+type@problem+block@d1e091aa305741c5bedfafed0d269efd": { - "id": "block-v1:QA+comparison+2022+type@problem+block@d1e091aa305741c5bedfafed0d269efd", - "block_id": "d1e091aa305741c5bedfafed0d269efd", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@d1e091aa305741c5bedfafed0d269efd", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@d1e091aa305741c5bedfafed0d269efd?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@problem+block@d1e091aa305741c5bedfafed0d269efd", - "type": "problem", - "display_name": "Blank Common Problem", - "graded": false, - "student_view_multi_device": true, - "block_counts": { - "video": 0 - }, - "descendants": [ - - ], - "completion": 0.0 - }, - "block-v1:QA+comparison+2022+type@comparison+block@23e10dea806345b19b77997b4fc0eea7": { - "id": "block-v1:QA+comparison+2022+type@comparison+block@23e10dea806345b19b77997b4fc0eea7", - "block_id": "23e10dea806345b19b77997b4fc0eea7", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@23e10dea806345b19b77997b4fc0eea7", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@23e10dea806345b19b77997b4fc0eea7?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@comparison+block@23e10dea806345b19b77997b4fc0eea7", - "type": "comparison", - "display_name": "Comparison", - "graded": false, - "student_view_multi_device": false, - "block_counts": { - "video": 0 - }, - "descendants": [ - - ], - "completion": 0.0 - }, - "block-v1:QA+comparison+2022+type@vertical+block@29e7eddbe8964770896e4036748c9904": { - "id": "block-v1:QA+comparison+2022+type@vertical+block@29e7eddbe8964770896e4036748c9904", - "block_id": "29e7eddbe8964770896e4036748c9904", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@29e7eddbe8964770896e4036748c9904", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@29e7eddbe8964770896e4036748c9904?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@vertical+block@29e7eddbe8964770896e4036748c9904", - "type": "vertical", - "display_name": "Unit", - "graded": false, - "student_view_multi_device": false, - "block_counts": { - "video": 0 - }, - "descendants": [ - "block-v1:QA+comparison+2022+type@comparison+block@be1704c576284ba39753c6f0ea4a4c78", - "block-v1:QA+comparison+2022+type@problem+block@93acc543871e4c73bc20a72a64e93296", - "block-v1:QA+comparison+2022+type@comparison+block@06c17035106e48328ebcd042babcf47b", - "block-v1:QA+comparison+2022+type@problem+block@c19e41b61db14efe9c45f1354332ae58", - "block-v1:QA+comparison+2022+type@problem+block@0d96732f577b4ff68799faf8235d1bfb", - "block-v1:QA+comparison+2022+type@problem+block@dd2e22fdf0724bd88c8b2e6b68dedd96", - "block-v1:QA+comparison+2022+type@problem+block@d1e091aa305741c5bedfafed0d269efd", - "block-v1:QA+comparison+2022+type@comparison+block@23e10dea806345b19b77997b4fc0eea7" - ], - "completion": 0 - }, - "block-v1:QA+comparison+2022+type@sequential+block@f468bb5c6e8641179e523c7fcec4e6d6": { - "id": "block-v1:QA+comparison+2022+type@sequential+block@f468bb5c6e8641179e523c7fcec4e6d6", - "block_id": "f468bb5c6e8641179e523c7fcec4e6d6", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@sequential+block@f468bb5c6e8641179e523c7fcec4e6d6", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@sequential+block@f468bb5c6e8641179e523c7fcec4e6d6?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@sequential+block@f468bb5c6e8641179e523c7fcec4e6d6", - "type": "sequential", - "display_name": "Subsection", - "graded": false, - "student_view_multi_device": false, - "block_counts": { - "video": 0 - }, - "descendants": [ - "block-v1:QA+comparison+2022+type@vertical+block@29e7eddbe8964770896e4036748c9904" - ], - "completion": 0 - }, - "block-v1:QA+comparison+2022+type@problem+block@eaf91d8fc70547339402043ba1a1c234": { - "id": "block-v1:QA+comparison+2022+type@problem+block@eaf91d8fc70547339402043ba1a1c234", - "block_id": "eaf91d8fc70547339402043ba1a1c234", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@eaf91d8fc70547339402043ba1a1c234", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@eaf91d8fc70547339402043ba1a1c234?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@problem+block@eaf91d8fc70547339402043ba1a1c234", - "type": "problem", - "display_name": "Dropdown with Hints and Feedback", - "graded": false, - "student_view_multi_device": true, - "block_counts": { - "video": 0 - }, - "descendants": [ - - ], - "completion": 0.0 - }, - "block-v1:QA+comparison+2022+type@comparison+block@fac531c3f1f3400cb8e3b97eb2c3d751": { - "id": "block-v1:QA+comparison+2022+type@comparison+block@fac531c3f1f3400cb8e3b97eb2c3d751", - "block_id": "fac531c3f1f3400cb8e3b97eb2c3d751", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@fac531c3f1f3400cb8e3b97eb2c3d751", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@comparison+block@fac531c3f1f3400cb8e3b97eb2c3d751?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@comparison+block@fac531c3f1f3400cb8e3b97eb2c3d751", - "type": "comparison", - "display_name": "Comparison", - "graded": false, - "student_view_multi_device": false, - "block_counts": { - "video": 0 - }, - "descendants": [ - - ], - "completion": 0.0 - }, - "block-v1:QA+comparison+2022+type@html+block@74a1074024fe401ea305534f2241e5de": { - "id": "block-v1:QA+comparison+2022+type@html+block@74a1074024fe401ea305534f2241e5de", - "block_id": "74a1074024fe401ea305534f2241e5de", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@html+block@74a1074024fe401ea305534f2241e5de", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@html+block@74a1074024fe401ea305534f2241e5de?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@html+block@74a1074024fe401ea305534f2241e5de", - "type": "html", - "display_name": "Raw HTML", - "graded": false, - "student_view_data": { - "last_modified": "2023-05-04T19:08:07Z", - "html_data": "https://s3.eu-central-1.amazonaws.com/vso-dev-edx-sorage/htmlxblock/QA/comparison/html/74a1074024fe401ea305534f2241e5de/content_html.zip", - "size": 576, - "index_page": "index.html", - "icon_class": "other" - }, - "student_view_multi_device": true, - "block_counts": { - "video": 0 - }, - "descendants": [ - - ], - "completion": 0.0 - }, - "block-v1:QA+comparison+2022+type@vertical+block@e5b2e105f4f947c5b76fb12c35da1eca": { - "id": "block-v1:QA+comparison+2022+type@vertical+block@e5b2e105f4f947c5b76fb12c35da1eca", - "block_id": "e5b2e105f4f947c5b76fb12c35da1eca", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@e5b2e105f4f947c5b76fb12c35da1eca", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@e5b2e105f4f947c5b76fb12c35da1eca?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@vertical+block@e5b2e105f4f947c5b76fb12c35da1eca", - "type": "vertical", - "display_name": "Unit", - "graded": false, - "student_view_multi_device": false, - "block_counts": { - "video": 0 - }, - "descendants": [ - "block-v1:QA+comparison+2022+type@problem+block@eaf91d8fc70547339402043ba1a1c234", - "block-v1:QA+comparison+2022+type@comparison+block@fac531c3f1f3400cb8e3b97eb2c3d751", - "block-v1:QA+comparison+2022+type@html+block@74a1074024fe401ea305534f2241e5de" - ], - "completion": 0 - }, - "block-v1:QA+comparison+2022+type@sequential+block@d37cb0c5c2d24ddaacf3494760a055f2": { - "id": "block-v1:QA+comparison+2022+type@sequential+block@d37cb0c5c2d24ddaacf3494760a055f2", - "block_id": "d37cb0c5c2d24ddaacf3494760a055f2", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@sequential+block@d37cb0c5c2d24ddaacf3494760a055f2", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@sequential+block@d37cb0c5c2d24ddaacf3494760a055f2?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@sequential+block@d37cb0c5c2d24ddaacf3494760a055f2", - "type": "sequential", - "display_name": "Another one subsection", - "graded": false, - "student_view_multi_device": false, - "block_counts": { - "video": 0 - }, - "descendants": [ - "block-v1:QA+comparison+2022+type@vertical+block@e5b2e105f4f947c5b76fb12c35da1eca" - ], - "completion": 0 - }, - "block-v1:QA+comparison+2022+type@chapter+block@abecaefe203c4c93b441d16cea3b7846": { - "id": "block-v1:QA+comparison+2022+type@chapter+block@abecaefe203c4c93b441d16cea3b7846", - "block_id": "abecaefe203c4c93b441d16cea3b7846", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@chapter+block@abecaefe203c4c93b441d16cea3b7846", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@chapter+block@abecaefe203c4c93b441d16cea3b7846?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@chapter+block@abecaefe203c4c93b441d16cea3b7846", - "type": "chapter", - "display_name": "Section", - "graded": false, - "student_view_multi_device": false, - "block_counts": { - "video": 0 - }, - "descendants": [ - "block-v1:QA+comparison+2022+type@sequential+block@f468bb5c6e8641179e523c7fcec4e6d6", - "block-v1:QA+comparison+2022+type@sequential+block@d37cb0c5c2d24ddaacf3494760a055f2" - ], - "completion": 0 - }, - "block-v1:QA+comparison+2022+type@pdf+block@a0c3ac29daab425f92a34b34eb2af9de": { - "id": "block-v1:QA+comparison+2022+type@pdf+block@a0c3ac29daab425f92a34b34eb2af9de", - "block_id": "a0c3ac29daab425f92a34b34eb2af9de", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@a0c3ac29daab425f92a34b34eb2af9de", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@a0c3ac29daab425f92a34b34eb2af9de?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@pdf+block@a0c3ac29daab425f92a34b34eb2af9de", - "type": "pdf", - "display_name": "PDF title", - "graded": false, - "student_view_data": { - "last_modified": "2023-04-26T08:43:45Z", - "url": "https://www.adobe.com/support/products/enterprise/knowledgecenter/media/c4611_sample_explain.pdf", - }, - "student_view_multi_device": false, - "block_counts": { - "video": 0 - }, - "descendants": [ - - ], - "completion": 0.0 - }, - "block-v1:QA+comparison+2022+type@pdf+block@bcd1b0f3015b4d3696b12f65a5d682f9": { - "id": "block-v1:QA+comparison+2022+type@pdf+block@bcd1b0f3015b4d3696b12f65a5d682f9", - "block_id": "bcd1b0f3015b4d3696b12f65a5d682f9", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@bcd1b0f3015b4d3696b12f65a5d682f9", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@bcd1b0f3015b4d3696b12f65a5d682f9?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@pdf+block@bcd1b0f3015b4d3696b12f65a5d682f9", - "type": "pdf", - "display_name": "PDF", - "graded": false, - "student_view_data": { - "last_modified": "2023-04-26T08:43:45Z", - "url": "https://www.adobe.com/support/products/enterprise/knowledgecenter/media/c4611_sample_explain.pdf", - }, - "student_view_multi_device": false, - "block_counts": { - "video": 0 - }, - "descendants": [ - - ], - "completion": 0.0 - }, - "block-v1:QA+comparison+2022+type@pdf+block@67d805daade34bd4b6ace607e6d48f59": { - "id": "block-v1:QA+comparison+2022+type@pdf+block@67d805daade34bd4b6ace607e6d48f59", - "block_id": "67d805daade34bd4b6ace607e6d48f59", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@67d805daade34bd4b6ace607e6d48f59", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@67d805daade34bd4b6ace607e6d48f59?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@pdf+block@67d805daade34bd4b6ace607e6d48f59", - "type": "pdf", - "display_name": "PDF", - "graded": false, - "student_view_data": { - "last_modified": "2023-04-26T08:43:45Z", - "url": "https://www.adobe.com/support/products/enterprise/knowledgecenter/media/c4611_sample_explain.pdf", - }, - "student_view_multi_device": false, - "block_counts": { - "video": 0 - }, - "descendants": [ - - ], - "completion": 0.0 - }, - "block-v1:QA+comparison+2022+type@pdf+block@828606a51f4e44198e92f86a45be7974": { - "id": "block-v1:QA+comparison+2022+type@pdf+block@828606a51f4e44198e92f86a45be7974", - "block_id": "828606a51f4e44198e92f86a45be7974", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@828606a51f4e44198e92f86a45be7974", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@828606a51f4e44198e92f86a45be7974?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@pdf+block@828606a51f4e44198e92f86a45be7974", - "type": "pdf", - "display_name": "PDF", - "graded": false, - "student_view_data": { - "last_modified": "2023-04-26T08:43:45Z", - "url": "https://www.adobe.com/support/products/enterprise/knowledgecenter/media/c4611_sample_explain.pdf", - }, - "student_view_multi_device": false, - "block_counts": { - "video": 0 - }, - "descendants": [ - - ], - "completion": 0.0 - }, - "block-v1:QA+comparison+2022+type@pdf+block@8646c3bc2184467b86e5ef01ecd452ee": { - "id": "block-v1:QA+comparison+2022+type@pdf+block@8646c3bc2184467b86e5ef01ecd452ee", - "block_id": "8646c3bc2184467b86e5ef01ecd452ee", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@8646c3bc2184467b86e5ef01ecd452ee", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@8646c3bc2184467b86e5ef01ecd452ee?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@pdf+block@8646c3bc2184467b86e5ef01ecd452ee", - "type": "pdf", - "display_name": "PDF", - "graded": false, - "student_view_data": { - "last_modified": "2023-04-26T08:43:45Z", - "url": "https://www.adobe.com/support/products/enterprise/knowledgecenter/media/c4611_sample_explain.pdf", - }, - "student_view_multi_device": false, - "block_counts": { - "video": 0 - }, - "descendants": [ - - ], - "completion": 0.0 - }, - "block-v1:QA+comparison+2022+type@vertical+block@e2faa0e62223489e91a41700865c5fc1": { - "id": "block-v1:QA+comparison+2022+type@vertical+block@e2faa0e62223489e91a41700865c5fc1", - "block_id": "e2faa0e62223489e91a41700865c5fc1", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@e2faa0e62223489e91a41700865c5fc1", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@e2faa0e62223489e91a41700865c5fc1?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@vertical+block@e2faa0e62223489e91a41700865c5fc1", - "type": "vertical", - "display_name": "Unit", - "graded": false, - "student_view_multi_device": false, - "block_counts": { - "video": 0 - }, - "descendants": [ - "block-v1:QA+comparison+2022+type@pdf+block@a0c3ac29daab425f92a34b34eb2af9de", - "block-v1:QA+comparison+2022+type@pdf+block@bcd1b0f3015b4d3696b12f65a5d682f9", - "block-v1:QA+comparison+2022+type@pdf+block@67d805daade34bd4b6ace607e6d48f59", - "block-v1:QA+comparison+2022+type@pdf+block@828606a51f4e44198e92f86a45be7974", - "block-v1:QA+comparison+2022+type@pdf+block@8646c3bc2184467b86e5ef01ecd452ee" - ], - "completion": 0 - }, - "block-v1:QA+comparison+2022+type@problem+block@0c5e89fa6d7a4fac8f7b26f2ca0bbe52": { - "id": "block-v1:QA+comparison+2022+type@problem+block@0c5e89fa6d7a4fac8f7b26f2ca0bbe52", - "block_id": "0c5e89fa6d7a4fac8f7b26f2ca0bbe52", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@0c5e89fa6d7a4fac8f7b26f2ca0bbe52", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@problem+block@0c5e89fa6d7a4fac8f7b26f2ca0bbe52?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@problem+block@0c5e89fa6d7a4fac8f7b26f2ca0bbe52", - "type": "problem", - "display_name": "Checkboxes with Hints and Feedback", - "graded": false, - "student_view_multi_device": true, - "block_counts": { - "video": 0 - }, - "descendants": [ - - ], - "completion": 0.0 - }, - "block-v1:QA+comparison+2022+type@vertical+block@8ba437d8b20d416d91a2d362b0c940a4": { - "id": "block-v1:QA+comparison+2022+type@vertical+block@8ba437d8b20d416d91a2d362b0c940a4", - "block_id": "8ba437d8b20d416d91a2d362b0c940a4", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@8ba437d8b20d416d91a2d362b0c940a4", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@8ba437d8b20d416d91a2d362b0c940a4?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@vertical+block@8ba437d8b20d416d91a2d362b0c940a4", - "type": "vertical", - "display_name": "Unit", - "graded": false, - "student_view_multi_device": false, - "block_counts": { - "video": 0 - }, - "descendants": [ - "block-v1:QA+comparison+2022+type@problem+block@0c5e89fa6d7a4fac8f7b26f2ca0bbe52" - ], - "completion": 0 - }, - "block-v1:QA+comparison+2022+type@pdf+block@021f70794f7349998e190b060260b70d": { - "id": "block-v1:QA+comparison+2022+type@pdf+block@021f70794f7349998e190b060260b70d", - "block_id": "021f70794f7349998e190b060260b70d", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@021f70794f7349998e190b060260b70d", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@pdf+block@021f70794f7349998e190b060260b70d?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@pdf+block@021f70794f7349998e190b060260b70d", - "type": "pdf", - "display_name": "PDF", - "graded": false, - "student_view_data": { - "last_modified": "2023-04-26T08:43:45Z", - "url": "https://www.adobe.com/support/products/enterprise/knowledgecenter/media/c4611_sample_explain.pdf", - }, - "student_view_multi_device": false, - "block_counts": { - "video": 0 - }, - "descendants": [ - - ], - "completion": 0.0 - }, - "block-v1:QA+comparison+2022+type@vertical+block@2c344115d3554ac58c140ec86e591aa1": { - "id": "block-v1:QA+comparison+2022+type@vertical+block@2c344115d3554ac58c140ec86e591aa1", - "block_id": "2c344115d3554ac58c140ec86e591aa1", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@2c344115d3554ac58c140ec86e591aa1", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@vertical+block@2c344115d3554ac58c140ec86e591aa1?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@vertical+block@2c344115d3554ac58c140ec86e591aa1", - "type": "vertical", - "display_name": "Unit", - "graded": false, - "student_view_multi_device": false, - "block_counts": { - "video": 0 - }, - "descendants": [ - "block-v1:QA+comparison+2022+type@pdf+block@021f70794f7349998e190b060260b70d" - ], - "completion": 0 - }, - "block-v1:QA+comparison+2022+type@sequential+block@6c9c6ba663b54c0eb9cbdcd0c6b4bebe": { - "id": "block-v1:QA+comparison+2022+type@sequential+block@6c9c6ba663b54c0eb9cbdcd0c6b4bebe", - "block_id": "6c9c6ba663b54c0eb9cbdcd0c6b4bebe", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@sequential+block@6c9c6ba663b54c0eb9cbdcd0c6b4bebe", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@sequential+block@6c9c6ba663b54c0eb9cbdcd0c6b4bebe?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@sequential+block@6c9c6ba663b54c0eb9cbdcd0c6b4bebe", - "type": "sequential", - "display_name": "Subsection", - "graded": false, - "student_view_multi_device": false, - "block_counts": { - "video": 0 - }, - "descendants": [ - "block-v1:QA+comparison+2022+type@vertical+block@e2faa0e62223489e91a41700865c5fc1", - "block-v1:QA+comparison+2022+type@vertical+block@8ba437d8b20d416d91a2d362b0c940a4", - "block-v1:QA+comparison+2022+type@vertical+block@2c344115d3554ac58c140ec86e591aa1" - ], - "completion": 0 - }, - "block-v1:QA+comparison+2022+type@chapter+block@d5a4f1f2f5314288aae400c270fb03f7": { - "id": "block-v1:QA+comparison+2022+type@chapter+block@d5a4f1f2f5314288aae400c270fb03f7", - "block_id": "d5a4f1f2f5314288aae400c270fb03f7", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@chapter+block@d5a4f1f2f5314288aae400c270fb03f7", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@chapter+block@d5a4f1f2f5314288aae400c270fb03f7?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@chapter+block@d5a4f1f2f5314288aae400c270fb03f7", - "type": "chapter", - "display_name": "PDF", - "graded": false, - "student_view_multi_device": false, - "block_counts": { - "video": 0 - }, - "descendants": [ - "block-v1:QA+comparison+2022+type@sequential+block@6c9c6ba663b54c0eb9cbdcd0c6b4bebe" - ], - "completion": 0 - }, - "block-v1:QA+comparison+2022+type@chapter+block@7ab45affb80f4846a60648ec6aff9fbf": { - "id": "block-v1:QA+comparison+2022+type@chapter+block@7ab45affb80f4846a60648ec6aff9fbf", - "block_id": "7ab45affb80f4846a60648ec6aff9fbf", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@chapter+block@7ab45affb80f4846a60648ec6aff9fbf", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@chapter+block@7ab45affb80f4846a60648ec6aff9fbf?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@chapter+block@7ab45affb80f4846a60648ec6aff9fbf", - "type": "chapter", - "display_name": "Section", - "graded": false, - "student_view_multi_device": false, - "block_counts": { - "video": 0 - }, - "descendants": [ - - ] - }, - "block-v1:QA+comparison+2022+type@course+block@course": { - "id": "block-v1:QA+comparison+2022+type@course+block@course", - "block_id": "course", - "lms_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@course+block@course", - "legacy_web_url": "https://lms.lilac-vso-dev.raccoongang.com/courses/course-v1:QA+comparison+2022/jump_to/block-v1:QA+comparison+2022+type@course+block@course?experience=legacy", - "student_view_url": "https://lms.lilac-vso-dev.raccoongang.com/xblock/block-v1:QA+comparison+2022+type@course+block@course", - "type": "course", - "display_name": "Comparison xblock test coursre", - "graded": false, - "student_view_multi_device": false, - "block_counts": { - "video": 0 - }, - "descendants": [ - "block-v1:QA+comparison+2022+type@chapter+block@abecaefe203c4c93b441d16cea3b7846", - "block-v1:QA+comparison+2022+type@chapter+block@d5a4f1f2f5314288aae400c270fb03f7", - "block-v1:QA+comparison+2022+type@chapter+block@7ab45affb80f4846a60648ec6aff9fbf" - ], - "completion": 0 - } - }, - "id": "course-v1:QA+comparison+2022", - "name": "Comparison xblock test coursre", - "number": "comparison", - "org": "QA", - "start": "2022-01-01T00:00:00Z", - "start_display": "01 january 2022 р.", - "start_type": "timestamp", - "end": null, - "courseware_access": { - "has_access": true, - "error_code": null, - "developer_message": null, - "user_message": null, - "additional_context_user_message": null, - "user_fragment": null - }, - "media": { - "image": { - "raw": "/asset-v1:QA+comparison+2022+type@asset+block@images_course_image.jpg", - "small": "/asset-v1:QA+comparison+2022+type@asset+block@images_course_image.jpg", - "large": "/asset-v1:QA+comparison+2022+type@asset+block@images_course_image.jpg" - } - }, - "certificate": { - - }, - "is_self_paced": false - } - """ - - private let courseDatesJSON: String = """ - { - "dates_banner_info": { - "missed_deadlines": false, - "content_type_gating_enabled": true, - "missed_gated_content": false, - "verified_upgrade_link": null - }, - "course_date_blocks": [ - { - "assignment_type": null, - "complete": null, - "date": "2023-08-30T15:00:00Z", - "date_type": "course-start-date", - "description": "", - "learner_has_access": true, - "link": "", - "link_text": "", - "title": "Course starts", - "extra_info": null, - "first_component_block_id": "" - }, - { - "assignment_type": "Problem Set", - "complete": true, - "date": "2023-09-14T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": true, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@ca19e125470846f2a36ad1225410e39a", - "link_text": "", - "title": "Problem Set 1", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@problem+block@bd89c1dd129240f99bb8c5cbe3f56530" - }, - { - "assignment_type": "Problem Set", - "complete": false, - "date": "2023-09-14T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": true, - "link": "", - "link_text": "", - "title": "Problem Set 1.1", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@problem+block@bd89c1dd129240f99bb8c5cbe3f56530a" - }, - { - "assignment_type": "Problem Set", - "complete": false, - "date": "2023-09-21T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": true, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@e137765987514da7851a59dedeb5ecec", - "link_text": "", - "title": "Problem Set 2", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@c99e81ffff4546e28fecab0a0c381abd" - }, - { - "assignment_type": "Problem Set", - "complete": true, - "date": "2023-09-21T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": true, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@e137765987514da7851a59dedeb5ececc", - "link_text": "", - "title": "Problem Set 2.1", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@c99e81ffff4546e28fecab0a0c381abdc" - }, - { - "assignment_type": "Problem Set", - "complete": false, - "date": "2023-09-21T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": false, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@e137765987514da7851a59dedeb5ececcc", - "link_text": "", - "title": "Problem Set 2.2", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@c99e81ffff4546e28fecab0a0c381abdcc" - }, - { - "assignment_type": "Problem Set", - "complete": false, - "date": "2023-09-28T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": true, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@bfe9eb02884a4812883ff9e543887968", - "link_text": "", - "title": "Problem Set 3", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@5e117d71433647eaa6de63434641c011" - }, - { - "assignment_type": "Midterm", - "complete": false, - "date": "2023-10-04T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": false, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@bb284b9c4ff04091951f77b50e3b72f4", - "link_text": "", - "title": "Midterm Exam (time limit removed due to grader issues)", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@vertical+block@ec1c5d83de6244d38b1f3ff4d32b6e17" - }, - { - "assignment_type": "Problem Set", - "complete": false, - "date": "2023-10-12T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": true, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@64f4d344ecdc48d2bef514882e6236ab", - "link_text": "", - "title": "Problem Set 4", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@eeb64a67e52e4f3e80656b9233204f25" - }, - { - "assignment_type": "Problem Set", - "complete": false, - "date": "2023-10-19T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": true, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@79d22d4ab4f740158930fca4e80d67db", - "link_text": "", - "title": "Problem Set 5", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@html+block@3dde572871fc4b6ebdb47722a184a514" - }, - { - "assignment_type": "Problem Set", - "complete": false, - "date": "2023-10-26T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": true, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@3d419098708e4bcd9209ffa31a4cb3dc", - "link_text": "", - "title": "Problem Set 6", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@problem+block@9b2a0176bf6a4c21ad4a63c2fce2d0cb" - }, - { - "assignment_type": "Final Exam", - "complete": false, - "date": "2023-10-31T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": false, - "link": "", - "link_text": "", - "title": "Final Exam (time limit removed due to grader issues)", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@vertical+block@e7b4f091d7ad457097d0bbda9d9af267" - }, - { - "assignment_type": "Finger Exercises", - "complete": false, - "date": "2023-11-01T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": true, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@221a4c17dba341d6a970a0d80343253c", - "link_text": "", - "title": "1. Introduction to Python (TIME: 1:03:12)", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@ad9387910b7e47069c452efebd7b36dd" - }, - { - "assignment_type": "Finger Exercises", - "complete": false, - "date": "2023-11-01T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": true, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@35f82f6c3ecb4e9e913dc279a9b73a9f", - "link_text": "", - "title": "2. Core Elements of Programs (TIME: 54:14)", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@8fb4fa767a204d41a6366c2bc53bea22" - }, - { - "assignment_type": "Finger Exercises", - "complete": false, - "date": "2023-11-01T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": true, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@62f08cc899344863a1ab678aee506dec", - "link_text": "", - "title": "3. Simple Algorithms (TIME: 41:06)", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@1f2b055948c9467492649b59e24e8fdc" - }, - { - "assignment_type": "Finger Exercises", - "complete": false, - "date": "2023-11-01T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": true, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@38007cdb67c44b46b124cdbce33510b5", - "link_text": "", - "title": "4. Functions (TIME: 1:08:06)", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@9dc4c11c46274b87964c7534b449d50a" - }, - { - "assignment_type": "Finger Exercises", - "complete": false, - "date": "2023-11-01T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": true, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@01df98c1e74a459b8fb20d2d785622cd", - "link_text": "", - "title": "5. Tuples and Lists", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@3464df78190b43948ba0507ef4287290" - }, - { - "assignment_type": "Finger Exercises", - "complete": false, - "date": "2023-11-01T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": true, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@8a590293a22e46dd9760ec917d122ec1", - "link_text": "", - "title": "6. Dictionaries", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@d2abc5b3db0d43ba90c5d3a25e95e2d5" - }, - { - "assignment_type": "Finger Exercises", - "complete": false, - "date": "2023-11-01T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": true, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@78648402e8bf4738ade97101cc1ba263", - "link_text": "", - "title": "7. Testing and Debugging", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@dd0621fbfe594e789b187a1e4f8406eb" - }, - { - "assignment_type": "Finger Exercises", - "complete": false, - "date": "2023-11-01T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": true, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@c81c3de20ec54c37a04a8b3d1806e82c", - "link_text": "", - "title": "8. Exceptions and Assertions", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@6038a1b2f8a340eb8cdb41c021d62234" - }, - { - "assignment_type": "Finger Exercises", - "complete": false, - "date": "2023-11-01T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": true, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@37cb9a5012e443bbaa776a80afd9c87a", - "link_text": "", - "title": "9. Classes and Inheritance", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@b87e596b827142f09e9664fac3ab0be0" - }, - { - "assignment_type": "Finger Exercises", - "complete": false, - "date": "2023-11-01T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": true, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@54cd6b1bbbbe40f294ac0b5664c03f1e", - "link_text": "", - "title": "10. An Extended Example", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@6bc79b1a29ac46a7857caa53a8e203d0" - }, - { - "assignment_type": "Finger Exercises", - "complete": false, - "date": "2023-11-01T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": true, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@1334ab336b1b4458b5c2972c50e903b2", - "link_text": "", - "title": "11. Computational Complexity", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@be73e5a3ee7847d98805a257189b9fad" - }, - { - "assignment_type": "Finger Exercises", - "complete": false, - "date": "2023-11-01T23:30:00Z", - "date_type": "assignment-due-date", - "description": "", - "learner_has_access": true, - "link": "https://courses.edx.org/courses/course-v1:MITx+6.00.1x+2T2023a/jump_to/block-v1:MITx+6.00.1x+2T2023a+type@sequential+block@a7387dbd3728491c8f834e29a73e0cf4", - "link_text": "", - "title": "12. Searching and Sorting Algorithms", - "extra_info": null, - "first_component_block_id": "block-v1:MITx+6.00.1x+2T2023a+type@video+block@fa7e29b3b95b4a3b963d1c5dfdd4e8f8" - }, - { - "assignment_type": null, - "complete": null, - "date": "2023-11-01T23:30:00Z", - "date_type": "course-end-date", - "description": "After the course ends, the course content will be archived and no longer active.", - "learner_has_access": true, - "link": "", - "link_text": "", - "title": "Course ends", - "extra_info": null, - "first_component_block_id": "" - }, - { - "assignment_type": null, - "complete": null, - "date": "2023-11-03T00:00:00Z", - "date_type": "certificate-available-date", - "description": "Day certificates will become available for passing verified learners.", - "learner_has_access": true, - "link": "", - "link_text": "", - "title": "Certificate Available", - "extra_info": null, - "first_component_block_id": "" - }, - { - "assignment_type": null, - "complete": null, - "date": "2023-11-23T12:34:28Z", - "date_type": "course-expired-date", - "description": "You lose all access to this course, including your progress.", - "learner_has_access": true, - "link": "", - "link_text": "", - "title": "Audit Access Expires", - "extra_info": null, - "first_component_block_id": "" - } - ], - "has_ended": false, - "learner_is_full_access": false, - "user_timezone": null - } - """ } #endif // swiftlint:enable all diff --git a/Course/Course/Data/Model/Data_CourseDates.swift b/Course/Course/Data/Model/Data_CourseDates.swift index b78691020..62d493dc4 100644 --- a/Course/Course/Data/Model/Data_CourseDates.swift +++ b/Course/Course/Data/Model/Data_CourseDates.swift @@ -58,6 +58,98 @@ public extension DataLayer { case missedGatedContent = "missed_gated_content" case verifiedUpgradeLink = "verified_upgrade_link" } + + var status: BannerInfoStatus? { + if upgradeToCompleteGraded { + return .upgradeToCompleteGradedBanner + } else if upgradeToReset { + return .upgradeToResetBanner + } else if resetDates { + return .resetDatesBanner + } else if showDatesTabBannerInfo { + return .datesTabInfoBanner + } + + return nil + } + + // Case 1 + private var showDatesTabBannerInfo: Bool { + return !missedDeadlines + } + + // Case 2 + private var upgradeToCompleteGraded: Bool { + return contentTypeGatingEnabled && !missedDeadlines + } + + // Case 3 + private var upgradeToReset: Bool { + return !upgradeToCompleteGraded && missedDeadlines && missedGatedContent + } + + // Case 4 + private var resetDates: Bool { + return !upgradeToCompleteGraded && missedDeadlines && !missedGatedContent + } + } + + enum BannerInfoStatus { + case datesTabInfoBanner + case upgradeToCompleteGradedBanner + case upgradeToResetBanner + case resetDatesBanner + + var header: String { + switch self { + case .datesTabInfoBanner: + CourseLocalization.CourseDates.ResetDate.TabInfoBanner.header + case .upgradeToCompleteGradedBanner: + CourseLocalization.CourseDates.ResetDate.UpgradeToCompleteGradedBanner.header + case .upgradeToResetBanner: + CourseLocalization.CourseDates.ResetDate.UpgradeToResetBanner.header + case .resetDatesBanner: + CourseLocalization.CourseDates.ResetDate.ResetDateBanner.header + } + } + + var body: String { + switch self { + case .datesTabInfoBanner: + CourseLocalization.CourseDates.ResetDate.TabInfoBanner.body + case .upgradeToCompleteGradedBanner: + CourseLocalization.CourseDates.ResetDate.UpgradeToCompleteGradedBanner.body + case .upgradeToResetBanner: + CourseLocalization.CourseDates.ResetDate.UpgradeToResetBanner.body + case .resetDatesBanner: + CourseLocalization.CourseDates.ResetDate.ResetDateBanner.body + } + } + + var buttonTitle: String { + switch self { + case .upgradeToCompleteGradedBanner, .upgradeToResetBanner: + // Mobile payments are not implemented yet and to avoid breaking appstore guidelines, + // upgrade button is hidden, which leads user to payments + "" + case .resetDatesBanner: + CourseLocalization.CourseDates.ResetDate.ResetDateBanner.button + default: + "" + } + } + } +} + +public extension DataLayer { + struct CourseDateBanner: Codable { + let datesBannerInfo: DatesBannerInfo + let hasEnded: Bool + + enum CodingKeys: String, CodingKey { + case datesBannerInfo = "dates_banner_info" + case hasEnded = "has_ended" + } } } @@ -68,7 +160,8 @@ public extension DataLayer.CourseDates { missedDeadlines: datesBannerInfo?.missedDeadlines ?? false, contentTypeGatingEnabled: datesBannerInfo?.contentTypeGatingEnabled ?? false, missedGatedContent: datesBannerInfo?.missedGatedContent ?? false, - verifiedUpgradeLink: datesBannerInfo?.verifiedUpgradeLink), + verifiedUpgradeLink: datesBannerInfo?.verifiedUpgradeLink, + status: datesBannerInfo?.status), courseDateBlocks: courseDateBlocks.map { block in CourseDateBlock( assignmentType: block.assignmentType, @@ -88,3 +181,17 @@ public extension DataLayer.CourseDates { userTimezone: userTimezone) } } + +public extension DataLayer.CourseDateBanner { + var domain: CourseDateBanner { + return CourseDateBanner( + datesBannerInfo: DatesBannerInfo( + missedDeadlines: datesBannerInfo.missedDeadlines, + contentTypeGatingEnabled: datesBannerInfo.contentTypeGatingEnabled, + missedGatedContent: datesBannerInfo.missedGatedContent, + verifiedUpgradeLink: datesBannerInfo.verifiedUpgradeLink, + status: datesBannerInfo.status), + hasEnded: hasEnded + ) + } +} diff --git a/Course/Course/Data/Network/CourseEndpoint.swift b/Course/Course/Data/Network/CourseEndpoint.swift index cdeedcd2d..2abafa14a 100644 --- a/Course/Course/Data/Network/CourseEndpoint.swift +++ b/Course/Course/Data/Network/CourseEndpoint.swift @@ -18,6 +18,8 @@ enum CourseEndpoint: EndPointType { case resumeBlock(userName: String, courseID: String) case getSubtitles(url: String, selectedLanguage: String) case getCourseDates(courseID: String) + case getCourseDeadlineInfo(courseID: String) + case courseDatesReset(courseID: String) var path: String { switch self { @@ -37,6 +39,10 @@ enum CourseEndpoint: EndPointType { return url case .getCourseDates(let courseID): return "/api/course_home/v1/dates/\(courseID)" + case .getCourseDeadlineInfo(let courseID): + return "/api/course_experience/v1/course_deadlines_info/\(courseID)" + case .courseDatesReset: + return "/api/course_experience/v1/reset_course_deadlines" } } @@ -58,6 +64,10 @@ enum CourseEndpoint: EndPointType { return .get case .getCourseDates: return .get + case .getCourseDeadlineInfo: + return .get + case .courseDatesReset: + return .post } } @@ -104,6 +114,10 @@ enum CourseEndpoint: EndPointType { return .requestParameters(parameters: params, encoding: URLEncoding.queryString) case .getCourseDates: return .requestParameters(encoding: JSONEncoding.default) + case .getCourseDeadlineInfo: + return .requestParameters(encoding: JSONEncoding.default) + case let .courseDatesReset(courseID): + return .requestParameters(parameters: ["course_key": courseID], encoding: JSONEncoding.default) } } } diff --git a/Course/Course/Domain/CourseInteractor.swift b/Course/Course/Domain/CourseInteractor.swift index aa2acabac..8f27a552f 100644 --- a/Course/Course/Domain/CourseInteractor.swift +++ b/Course/Course/Domain/CourseInteractor.swift @@ -19,6 +19,8 @@ public protocol CourseInteractorProtocol { func resumeBlock(courseID: String) async throws -> ResumeBlock func getSubtitles(url: String, selectedLanguage: String) async throws -> [Subtitle] func getCourseDates(courseID: String) async throws -> CourseDates + func getCourseDeadlineInfo(courseID: String) async throws -> CourseDateBanner + func shiftDueDates(courseID: String) async throws } public class CourseInteractor: CourseInteractorProtocol { @@ -63,6 +65,10 @@ public class CourseInteractor: CourseInteractorProtocol { return try await repository.blockCompletionRequest(courseID: courseID, blockID: blockID) } + public func shiftDueDates(courseID: String) async throws { + return try await repository.shiftDueDates(courseID: courseID) + } + public func getHandouts(courseID: String) async throws -> String? { return try await repository.getHandouts(courseID: courseID) } @@ -84,6 +90,10 @@ public class CourseInteractor: CourseInteractorProtocol { return try await repository.getCourseDates(courseID: courseID) } + public func getCourseDeadlineInfo(courseID: String) async throws -> CourseDateBanner { + return try await repository.getCourseDeadlineInfo(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 index a796a944b..9a16160d3 100644 --- a/Course/Course/Domain/Model/CourseDates.swift +++ b/Course/Course/Domain/Model/CourseDates.swift @@ -235,6 +235,12 @@ public struct CourseDateBlock: Identifiable { public struct DatesBannerInfo { let missedDeadlines, contentTypeGatingEnabled, missedGatedContent: Bool let verifiedUpgradeLink: String? + let status: DataLayer.BannerInfoStatus? +} + +public struct CourseDateBanner { + let datesBannerInfo: DatesBannerInfo + let hasEnded: Bool } public enum BlockStatus { diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index f2533d987..ea30c67e3 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -70,7 +70,14 @@ public struct CourseContainerView: View { ) { self.viewModel = viewModel Task { - await viewModel.getCourseBlocks(courseID: courseID) + await withTaskGroup(of: Void.self) { group in + group.addTask { + await viewModel.getCourseBlocks(courseID: courseID) + } + group.addTask { + await viewModel.getCourseDeadlineInfo(courseID: courseID, withProgress: false) + } + } } self.courseID = courseID self.title = title @@ -95,7 +102,9 @@ public struct CourseContainerView: View { viewModel: viewModel, title: title, courseID: courseID, - isVideo: false + isVideo: false, + selection: $selection, + dateTabIndex: CourseTab.dates.rawValue ) } else { VStack(spacing: 0) { @@ -130,7 +139,9 @@ public struct CourseContainerView: View { viewModel: viewModel, title: title, courseID: courseID, - isVideo: false + isVideo: false, + selection: $selection, + dateTabIndex: CourseTab.dates.rawValue ) .tabItem { tab.image @@ -143,7 +154,9 @@ public struct CourseContainerView: View { viewModel: viewModel, title: title, courseID: courseID, - isVideo: true + isVideo: true, + selection: $selection, + dateTabIndex: CourseTab.dates.rawValue ) .tabItem { tab.image diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index d4aa3913c..3398ac421 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -14,6 +14,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { @Published private(set) var isShowProgress = false @Published var courseStructure: CourseStructure? + @Published var courseDeadlineInfo: CourseDateBanner? @Published var courseVideosStructure: CourseStructure? @Published var showError: Bool = false @Published var sequentialsDownloadState: [String: DownloadViewState] = [:] @@ -21,6 +22,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { @Published var continueWith: ContinueWith? @Published var userSettings: UserSettings? @Published var isInternetAvaliable: Bool = true + @Published var dueDatesShifted: Bool = false var errorMessage: String? { didSet { @@ -79,47 +81,77 @@ public class CourseContainerViewModel: BaseCourseViewModel { self.isInternetAvaliable = connectivity.isInternetAvaliable super.init(manager: manager) - addObservers() } @MainActor func getCourseBlocks(courseID: String, withProgress: Bool = true) async { - if let courseStart { - if courseStart < Date() { - isShowProgress = withProgress - do { - if isInternetAvaliable { - courseStructure = try await interactor.getCourseBlocks(courseID: courseID) - isShowProgress = false - if let courseStructure { - let continueWith = try await getResumeBlock( - courseID: courseID, - courseStructure: courseStructure - ) - withAnimation { - self.continueWith = continueWith - } - } - } else { - courseStructure = try await interactor.getLoadedCourseBlocks(courseID: courseID) - } - courseVideosStructure = interactor.getCourseVideoBlocks(fullStructure: courseStructure!) - await setDownloadsStates() - isShowProgress = false - - } catch let error { - isShowProgress = false - if error.isInternetError || error is NoCachedDataError { - errorMessage = CoreLocalization.Error.slowOrNoInternetConnection - } else { - errorMessage = CoreLocalization.Error.unknownError + guard let courseStart, courseStart < Date() else { return } + + isShowProgress = withProgress + do { + if isInternetAvaliable { + courseStructure = try await interactor.getCourseBlocks(courseID: courseID) + isShowProgress = false + if let courseStructure { + let continueWith = try await getResumeBlock( + courseID: courseID, + courseStructure: courseStructure + ) + withAnimation { + self.continueWith = continueWith } } + } else { + courseStructure = try await interactor.getLoadedCourseBlocks(courseID: courseID) + } + courseVideosStructure = interactor.getCourseVideoBlocks(fullStructure: courseStructure!) + await setDownloadsStates() + isShowProgress = false + + } catch let error { + isShowProgress = false + if error.isInternetError || error is NoCachedDataError { + errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } else { + errorMessage = CoreLocalization.Error.unknownError + } + } + } + + @MainActor + func getCourseDeadlineInfo(courseID: String, withProgress: Bool = true) async { + do { + let courseDeadlineInfo = try await interactor.getCourseDeadlineInfo(courseID: courseID) + withAnimation { + self.courseDeadlineInfo = courseDeadlineInfo + } + } catch let error { + if error.isInternetError || error is NoCachedDataError { + errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } else { + errorMessage = CoreLocalization.Error.unknownError } } } + @MainActor + func shiftDueDates(courseID: String, withProgress: Bool = true) async { + isShowProgress = withProgress + do { + try await interactor.shiftDueDates(courseID: courseID) + NotificationCenter.default.post(name: .shiftCourseDates, object: courseID) + isShowProgress = false + } catch let error { + isShowProgress = false + if error.isInternetError || error is NoCachedDataError { + errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } else { + errorMessage = CoreLocalization.Error.unknownError + } + } + } + func update(downloadQuality: DownloadQuality) { storage.userSettings?.downloadQuality = downloadQuality userSettings = storage.userSettings @@ -430,6 +462,40 @@ public class CourseContainerViewModel: BaseCourseViewModel { self.isInternetAvaliable = self.connectivity.isInternetAvaliable } .store(in: &cancellables) + + NotificationCenter.default.addObserver( + self, + selector: #selector(handleShiftDueDates), + name: .shiftCourseDates, object: nil + ) + } + + deinit { + NotificationCenter.default.removeObserver(self) + } +} + +extension CourseContainerViewModel { + @objc private func handleShiftDueDates(_ notification: Notification) { + if let courseID = notification.object as? String { + Task { + await withTaskGroup(of: Void.self) { group in + group.addTask { + await self.getCourseBlocks(courseID: courseID, withProgress: true) + } + group.addTask { + await self.getCourseDeadlineInfo(courseID: courseID, withProgress: true) + } + await MainActor.run { [weak self] in + self?.dueDatesShifted = true + } + } + } + } + } + + func resetDueDatesShiftedFlag() { + dueDatesShifted = false } } diff --git a/Course/Course/Presentation/Dates/CourseDatesView.swift b/Course/Course/Presentation/Dates/CourseDatesView.swift index b49356a70..b4589c92f 100644 --- a/Course/Course/Presentation/Dates/CourseDatesView.swift +++ b/Course/Course/Presentation/Dates/CourseDatesView.swift @@ -35,10 +35,15 @@ public struct CourseDatesView: View { .padding(.horizontal) } } else if let courseDates = viewModel.courseDates, !courseDates.courseDateBlocks.isEmpty { - CourseDateListView(viewModel: viewModel, courseDates: courseDates) + CourseDateListView(viewModel: viewModel, courseDates: courseDates, courseID: courseID) .padding(.top, 10) } } + + if viewModel.dueDatesShifted { + DatesShiftedSuccessView(selectedTab: .dates, courseDatesViewModel: viewModel) + } + if viewModel.showError { VStack { Spacer() @@ -95,11 +100,21 @@ struct CourseDateListView: View { @ObservedObject var viewModel: CourseDatesViewModel @State private var isExpanded = false var courseDates: CourseDates + let courseID: String var body: some View { VStack { ScrollView { VStack(alignment: .leading, spacing: 0) { + if !courseDates.hasEnded { + DatesStatusInfoView( + datesBannerInfo: courseDates.datesBannerInfo, + courseID: courseID, + courseDatesViewModel: viewModel + ) + .padding(.bottom, 16) + } + ForEach(Array(viewModel.sortedStatuses), id: \.self) { status in let courseDateBlockDict = courseDates.statusDatesBlocks[status]! if status == .completed { @@ -165,8 +180,8 @@ struct CompletedBlocks: View { if !isExpanded { let totalCount = courseDateBlockDict.values.reduce(0) { $0 + $1.count } let itemsHidden = totalCount == 1 ? - CoreLocalization.CourseDates.itemHidden : - CoreLocalization.CourseDates.itemsHidden + CourseLocalization.CourseDates.itemHidden : + CourseLocalization.CourseDates.itemsHidden Text("\(totalCount) \(itemsHidden)") .font(Theme.Fonts.labelMedium) .foregroundColor(Theme.Colors.textPrimary) @@ -321,11 +336,11 @@ struct StyleBlock: View { 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 + case .completed: return CourseLocalization.CourseDates.completed + case .pastDue: return CourseLocalization.CourseDates.pastDue + case .dueNext: return CourseLocalization.CourseDates.dueNext + case .unreleased: return CourseLocalization.CourseDates.unreleased + case .verifiedOnly: return CourseLocalization.CourseDates.verifiedOnly default: return "" } } diff --git a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift index 3ee731bd0..a8d2f0f72 100644 --- a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift +++ b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift @@ -14,6 +14,7 @@ public class CourseDatesViewModel: ObservableObject { @Published private(set) var isShowProgress = false @Published var showError: Bool = false @Published var courseDates: CourseDates? + @Published var dueDatesShifted: Bool = false var errorMessage: String? { didSet { @@ -41,6 +42,7 @@ public class CourseDatesViewModel: ObservableObject { self.cssInjector = cssInjector self.connectivity = connectivity self.courseID = courseID + addObservers() } var sortedStatuses: [CompletionStatus] { @@ -91,4 +93,50 @@ public class CourseDatesViewModel: ObservableObject { errorMessage = CourseLocalization.Error.componentNotFount } } + + @MainActor + func shiftDueDates(courseID: String, withProgress: Bool = true) async { + isShowProgress = withProgress + do { + try await interactor.shiftDueDates(courseID: courseID) + NotificationCenter.default.post(name: .shiftCourseDates, object: courseID) + isShowProgress = false + } catch let error { + isShowProgress = false + if error.isInternetError || error is NoCachedDataError { + errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } else { + errorMessage = CoreLocalization.Error.unknownError + } + } + } + + deinit { + NotificationCenter.default.removeObserver(self) + } +} + +extension CourseDatesViewModel { + private func addObservers() { + NotificationCenter.default.addObserver( + self, + selector: #selector(handleShiftDueDates), + name: .shiftCourseDates, object: nil + ) + } + + @objc private func handleShiftDueDates(_ notification: Notification) { + if let courseID = notification.object as? String { + Task { + await getCourseDates(courseID: courseID) + await MainActor.run { [weak self] in + self?.dueDatesShifted = true + } + } + } + } + + func resetDueDatesShiftedFlag() { + dueDatesShifted = false + } } diff --git a/Course/Course/Presentation/Dates/DatesStatusInfoView.swift b/Course/Course/Presentation/Dates/DatesStatusInfoView.swift new file mode 100644 index 000000000..38a878dfe --- /dev/null +++ b/Course/Course/Presentation/Dates/DatesStatusInfoView.swift @@ -0,0 +1,82 @@ +// +// DatesStatusInfoView.swift +// Course +// +// Created by Shafqat Muneer on 2/14/24. +// + +import Foundation +import SwiftUI +import Core +import Theme + +struct DatesStatusInfoView: View { + let datesBannerInfo: DatesBannerInfo + let courseID: String + var courseDatesViewModel: CourseDatesViewModel? + var courseContainerViewModel: CourseContainerViewModel? + @State private var isLoading = false + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + let header = datesBannerInfo.status?.header ?? "" + let button = datesBannerInfo.status?.buttonTitle ?? "" + Spacer() + if !header.isEmpty { + Text(header) + .frame(maxWidth: .infinity, alignment: .leading) + .font(Theme.Fonts.titleMedium) + .foregroundColor(Theme.Colors.textPrimary) + .padding(.horizontal, 16) + } + + Text(datesBannerInfo.status?.body ?? "") + .frame(maxWidth: .infinity, alignment: .leading) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + .padding(.horizontal, 16) + + if !button.isEmpty { + UnitButtonView(type: .custom(button)) { + guard !isLoading else { return } + isLoading = true + Task { + if courseDatesViewModel != nil { + await courseDatesViewModel?.shiftDueDates(courseID: courseID) + } else if courseContainerViewModel != nil { + await courseContainerViewModel?.shiftDueDates(courseID: courseID) + } + isLoading = false + } + } + .padding([.leading, .trailing], 16) + .disabled(isLoading) + } + Spacer() + } + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.datesSectionStroke, lineWidth: 2) + ) + .background(Theme.Colors.datesSectionBackground) + } +} + +#if DEBUG +struct DatesStatusInfoView_Previews: PreviewProvider { + static var previews: some View { + let datesBannerInfo = DatesBannerInfo( + missedDeadlines: true, + contentTypeGatingEnabled: false, + missedGatedContent: false, + verifiedUpgradeLink: "https://ecommerce.edx.org/basket/add/?sku=87701A8", + status: .resetDatesBanner + ) + + DatesStatusInfoView( + datesBannerInfo: datesBannerInfo, + courseID: "courseID" + ) + } +} +#endif diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 043e7a609..b4ebb132e 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -16,6 +16,7 @@ public struct CourseOutlineView: View { private let title: String private let courseID: String private let isVideo: Bool + private let dateTabIndex: Int @State private var openCertificateView: Bool = false private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @@ -23,17 +24,22 @@ public struct CourseOutlineView: View { @State private var showingDownloads: Bool = false @State private var showingVideoDownloadQuality: Bool = false @State private var showingNoWifiMessage: Bool = false + @Binding private var selection: Int public init( viewModel: CourseContainerViewModel, title: String, courseID: String, - isVideo: Bool + isVideo: Bool, + selection: Binding, + dateTabIndex: Int ) { self.title = title self._viewModel = StateObject(wrappedValue: { viewModel }()) self.courseID = courseID self.isVideo = isVideo + self._selection = selection + self.dateTabIndex = dateTabIndex } public var body: some View { @@ -43,9 +49,28 @@ public struct CourseOutlineView: View { VStack(alignment: .center) { // MARK: - Page Body RefreshableScrollViewCompat(action: { - await viewModel.getCourseBlocks(courseID: courseID, withProgress: false) + await withTaskGroup(of: Void.self) { group in + group.addTask { + await viewModel.getCourseBlocks(courseID: courseID, withProgress: false) + } + group.addTask { + await viewModel.getCourseDeadlineInfo(courseID: courseID, withProgress: false) + } + } }) { VStack(alignment: .leading) { + if let courseDeadlineInfo = viewModel.courseDeadlineInfo, + courseDeadlineInfo.datesBannerInfo.status == .resetDatesBanner, + !courseDeadlineInfo.hasEnded, + !isVideo { + DatesStatusInfoView( + datesBannerInfo: courseDeadlineInfo.datesBannerInfo, + courseID: courseID, + courseContainerViewModel: viewModel + ) + .padding(.horizontal, 16) + .padding(.top, 16) + } if viewModel.config.uiComponents.courseBannerEnabled { courseBanner(proxy: proxy) } @@ -127,11 +152,24 @@ public struct CourseOutlineView: View { .padding(.top, viewModel.config.uiComponents.courseTopTabBarEnabled ? 0 : 8) .accessibilityAction {} + if viewModel.dueDatesShifted && !isVideo { + DatesShiftedSuccessView(selectedTab: .course, courseContainerViewModel: viewModel) { + selection = dateTabIndex + } + } + // MARK: - Offline mode SnackBar OfflineSnackBarView( connectivity: viewModel.connectivity, reloadAction: { - await viewModel.getCourseBlocks(courseID: courseID, withProgress: false) + await withTaskGroup(of: Void.self) { group in + group.addTask { + await viewModel.getCourseBlocks(courseID: courseID, withProgress: false) + } + group.addTask { + await viewModel.getCourseDeadlineInfo(courseID: courseID, withProgress: false) + } + } } ) @@ -283,6 +321,7 @@ public struct CourseOutlineView: View { #if DEBUG struct CourseOutlineView_Previews: PreviewProvider { static var previews: some View { + @State var selection: Int = 0 let viewModel = CourseContainerViewModel( interactor: CourseInteractor.mock, authInteractor: AuthInteractor.mock, @@ -299,7 +338,14 @@ struct CourseOutlineView_Previews: PreviewProvider { enrollmentEnd: nil ) Task { - await viewModel.getCourseBlocks(courseID: "courseId") + await withTaskGroup(of: Void.self) { group in + group.addTask { + await viewModel.getCourseBlocks(courseID: "courseId") + } + group.addTask { + await viewModel.getCourseDeadlineInfo(courseID: "courseId") + } + } } return Group { @@ -307,7 +353,9 @@ struct CourseOutlineView_Previews: PreviewProvider { viewModel: viewModel, title: "Course title", courseID: "", - isVideo: false + isVideo: false, + selection: $selection, + dateTabIndex: 2 ) .preferredColorScheme(.light) .previewDisplayName("CourseOutlineView Light") @@ -316,7 +364,9 @@ struct CourseOutlineView_Previews: PreviewProvider { viewModel: viewModel, title: "Course title", courseID: "", - isVideo: false + isVideo: false, + selection: $selection, + dateTabIndex: 2 ) .preferredColorScheme(.dark) .previewDisplayName("CourseOutlineView Dark") diff --git a/Course/Course/SwiftGen/Strings.swift b/Course/Course/SwiftGen/Strings.swift index 3439215f8..d48da2e62 100644 --- a/Course/Course/SwiftGen/Strings.swift +++ b/Course/Course/SwiftGen/Strings.swift @@ -68,6 +68,68 @@ public enum CourseLocalization { /// Videos public static let videos = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.VIDEOS", fallback: "Videos") } + public enum CourseDates { + /// Completed + public static let completed = CourseLocalization.tr("Localizable", "COURSE_DATES.COMPLETED", fallback: "Completed") + /// Due next + public static let dueNext = CourseLocalization.tr("Localizable", "COURSE_DATES.DUE_NEXT", fallback: "Due next") + /// Item Hidden + public static let itemHidden = CourseLocalization.tr("Localizable", "COURSE_DATES.ITEM_HIDDEN", fallback: "Item Hidden") + /// Items Hidden + public static let itemsHidden = CourseLocalization.tr("Localizable", "COURSE_DATES.ITEMS_HIDDEN", fallback: "Items Hidden") + /// Past due + public static let pastDue = CourseLocalization.tr("Localizable", "COURSE_DATES.PAST_DUE", fallback: "Past due") + /// Your due dates have been successfully shifted to help you stay on track. + public static let toastSuccessMessage = CourseLocalization.tr("Localizable", "COURSE_DATES.TOAST_SUCCESS_MESSAGE", fallback: "Your due dates have been successfully shifted to help you stay on track.") + /// Due dates shifted + public static let toastSuccessTitle = CourseLocalization.tr("Localizable", "COURSE_DATES.TOAST_SUCCESS_TITLE", fallback: "Due dates shifted") + /// Today + public static let today = CourseLocalization.tr("Localizable", "COURSE_DATES.TODAY", fallback: "Today") + /// Unreleased + public static let unreleased = CourseLocalization.tr("Localizable", "COURSE_DATES.UNRELEASED", fallback: "Unreleased") + /// Verified Only + public static let verifiedOnly = CourseLocalization.tr("Localizable", "COURSE_DATES.VERIFIED_ONLY", fallback: "Verified Only") + /// View all dates + public static let viewAllDates = CourseLocalization.tr("Localizable", "COURSE_DATES.VIEW_ALL_DATES", fallback: "View all dates") + public enum ResetDate { + /// Your dates could not be shifted. Please try again. + public static let errorMessage = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.ERROR_MESSAGE", fallback: "Your dates could not be shifted. Please try again.") + /// Your dates have been successfully shifted. + public static let successMessage = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.SUCCESS_MESSAGE", fallback: "Your dates have been successfully shifted.") + /// Course Dates + public static let title = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.TITLE", fallback: "Course Dates") + public enum ResetDateBanner { + /// Don't worry - shift our suggested schedule to complete past due assignments without losing any progress. + public static let body = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BODY", fallback: "Don't worry - shift our suggested schedule to complete past due assignments without losing any progress.") + /// Shift due dates + public static let button = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BUTTON", fallback: "Shift due dates") + /// Missed some deadlines? + public static let header = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.HEADER", fallback: "Missed some deadlines?") + } + public enum TabInfoBanner { + /// We built a suggested schedule to help you stay on track. But don’t worry – it’s flexible so you can learn at your own pace. If you happen to fall behind, you’ll be able to adjust the dates to keep yourself on track. + public static let body = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.BODY", fallback: "We built a suggested schedule to help you stay on track. But don’t worry – it’s flexible so you can learn at your own pace. If you happen to fall behind, you’ll be able to adjust the dates to keep yourself on track.") + /// + public static let header = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.HEADER", fallback: "") + } + public enum UpgradeToCompleteGradedBanner { + /// To complete graded assignments as part of this course, you can upgrade today. + public static let body = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BODY", fallback: "To complete graded assignments as part of this course, you can upgrade today.") + /// + public static let button = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BUTTON", fallback: "") + /// + public static let header = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.HEADER", fallback: "") + } + public enum UpgradeToResetBanner { + /// You are auditing this course, which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today. + public static let body = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY", fallback: "You are auditing this course, which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today.") + /// + public static let button = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON", fallback: "") + /// + public static let header = CourseLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.HEADER", fallback: "") + } + } + } public enum Download { /// All videos downloaded public static let allVideosDownloaded = CourseLocalization.tr("Localizable", "DOWNLOAD.ALL_VIDEOS_DOWNLOADED", fallback: "All videos downloaded") diff --git a/Course/Course/Views/DatesShiftedSuccessView.swift b/Course/Course/Views/DatesShiftedSuccessView.swift new file mode 100644 index 000000000..87c2ee9b0 --- /dev/null +++ b/Course/Course/Views/DatesShiftedSuccessView.swift @@ -0,0 +1,113 @@ +// +// DatesShiftedSuccessView.swift +// Core +// +// Created by Shafqat Muneer on 2/18/24. +// + +import SwiftUI +import Combine +import Theme + +public struct DatesShiftedSuccessView: View { + + enum Tab { + case course + case dates + } + + var selectedTab: Tab + var courseDatesViewModel: CourseDatesViewModel? + var courseContainerViewModel: CourseContainerViewModel? + var action: () async -> Void = {} + + @State private var dismiss: Bool = false + + public var body: some View { + ZStack { + VStack { + Spacer() + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top) { + Text(CourseLocalization.CourseDates.toastSuccessTitle) + .foregroundStyle(Theme.Colors.textPrimary) + .font(Theme.Fonts.titleMedium) + Spacer() + Button { + withAnimation { + dismissView() + } + } label: { + Image(systemName: "xmark") + .resizable() + .frame(width: 14.0, height: 14.0) + } + .padding(.all, 5) + .tint(Theme.Colors.textPrimary) + } + + Text(CourseLocalization.CourseDates.toastSuccessMessage) + .foregroundStyle(Theme.Colors.textPrimary) + .font(Theme.Fonts.labelLarge) + + if selectedTab == .course { + Button(CourseLocalization.CourseDates.viewAllDates, + action: { + Task { + await action() + } + withAnimation { + dismissView() + } + }) + .frame(maxWidth: .infinity) + .padding(.vertical, 11) + .background(Theme.Colors.datesSectionBackground) + .foregroundStyle(Theme.Colors.secondaryButtonBorderColor) + .clipShape(RoundedRectangle(cornerRadius: 8)) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.secondaryButtonBorderColor, lineWidth: 1) + ) + } + } + .font(Theme.Fonts.titleSmall) + .padding(.all, 16) + .background(Theme.Colors.datesSectionBackground) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + .frame(maxWidth: .infinity) + .padding(.all, 10) + .shadow(color: .black.opacity(0.25), radius: 5, y: 4) + } + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 5) { + withAnimation { + dismissView() + } + } + } + .onDisappear { + withAnimation { + dismissView() + } + } + .offset(y: dismiss ? 100 : 0) + .opacity(dismiss ? 0 : 1) + .transition(.move(edge: .bottom)) + } + + private func dismissView() { + dismiss = true + courseDatesViewModel?.resetDueDatesShiftedFlag() + courseContainerViewModel?.resetDueDatesShiftedFlag() + } +} + +#if DEBUG +struct DatesShiftedSuccessView_Previews: PreviewProvider { + static var previews: some View { + DatesShiftedSuccessView(selectedTab: .course) + } +} +#endif diff --git a/Course/Course/en.lproj/Localizable.strings b/Course/Course/en.lproj/Localizable.strings index e412863b5..aede5dc86 100644 --- a/Course/Course/en.lproj/Localizable.strings +++ b/Course/Course/en.lproj/Localizable.strings @@ -71,3 +71,34 @@ "DOWNLOAD.CHANGE_QUALITY_ALERT" = "You cannot change the download video quality when all videos are downloading"; "DOWNLOAD.DOWNLOAD_LARGE_FILE_MESSAGE" = "The videos you've selected are larger than 1 GB. Do you want to download these videos?"; "DOWNLOAD.NO_WIFI_MESSAGE" = "Your current download settings only allow downloads over Wi-Fi.\nPlease connect to a Wi-Fi network or change your download settings."; + +"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"; +"COURSE_DATES.ITEMS_HIDDEN" = "Items Hidden"; +"COURSE_DATES.ITEM_HIDDEN" = "Item Hidden"; +"COURSE_DATES.TOAST_SUCCESS_TITLE" = "Due dates shifted"; +"COURSE_DATES.TOAST_SUCCESS_MESSAGE" = "Your due dates have been successfully shifted to help you stay on track."; +"COURSE_DATES.VIEW_ALL_DATES" = "View all dates"; + +"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BODY" = "Don't worry - shift our suggested schedule to complete past due assignments without losing any progress."; +"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BUTTON" = "Shift due dates"; +"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.HEADER" = "Missed some deadlines?"; + +"COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.BODY" = "We built a suggested schedule to help you stay on track. But don’t worry – it’s flexible so you can learn at your own pace. If you happen to fall behind, you’ll be able to adjust the dates to keep yourself on track."; +"COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.HEADER" = ""; + +"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BODY" = "To complete graded assignments as part of this course, you can upgrade today."; +"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BUTTON" = ""; +"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.HEADER" = ""; + +"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY" = "You are auditing this course, which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today."; +"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON" = ""; +"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.HEADER" = ""; + +"COURSE_DATES.RESET_DATE.ERROR_MESSAGE" = "Your dates could not be shifted. Please try again."; +"COURSE_DATES.RESET_DATE.SUCCESS_MESSAGE" = "Your dates have been successfully shifted."; +"COURSE_DATES.RESET_DATE.TITLE" = "Course Dates"; diff --git a/Course/Course/uk.lproj/Localizable.strings b/Course/Course/uk.lproj/Localizable.strings index 602a2fd5b..746d539b4 100644 --- a/Course/Course/uk.lproj/Localizable.strings +++ b/Course/Course/uk.lproj/Localizable.strings @@ -70,3 +70,34 @@ "DOWNLOAD.CHANGE_QUALITY_ALERT" = "You cannot change the download video quality when all videos are downloading"; "DOWNLOAD.DOWNLOAD_LARGE_FILE_MESSAGE" = "The videos you've selected are larger than 1 GB. Do you want to download these videos?"; "DOWNLOAD.NO_WIFI_MESSAGE" = "Your current download settings only allow downloads over Wi-Fi.\nPlease connect to a Wi-Fi network or change your download settings."; + +"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"; +"COURSE_DATES.ITEMS_HIDDEN" = "Items Hidden"; +"COURSE_DATES.ITEM_HIDDEN" = "Item Hidden"; +"COURSE_DATES.TOAST_SUCCESS_TITLE" = "Due dates shifted"; +"COURSE_DATES.TOAST_SUCCESS_MESSAGE" = "Your due dates have been successfully shifted to help you stay on track."; +"COURSE_DATES.VIEW_ALL_DATES" = "View all dates"; + +"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BODY" = "Don't worry - shift our suggested schedule to complete past due assignments without losing any progress."; +"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BUTTON" = "Shift due dates"; +"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.HEADER" = "Missed some deadlines?"; + +"COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.BODY" = "We built a suggested schedule to help you stay on track. But don’t worry – it’s flexible so you can learn at your own pace. If you happen to fall behind, you’ll be able to adjust the dates to keep yourself on track."; +"COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.HEADER" = ""; + +"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BODY" = "To complete graded assignments as part of this course, you can upgrade today."; +"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BUTTON" = ""; +"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.HEADER" = ""; + +"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BODY" = "You are auditing this course, which means that you are unable to participate in graded assignments. It looks like you missed some important deadlines based on our suggested schedule. To complete graded assignments as part of this course and shift the past due assignments into the future, you can upgrade today."; +"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON" = ""; +"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.HEADER" = ""; + +"COURSE_DATES.RESET_DATE.ERROR_MESSAGE" = "Your dates could not be shifted. Please try again."; +"COURSE_DATES.RESET_DATE.SUCCESS_MESSAGE" = "Your dates have been successfully shifted."; +"COURSE_DATES.RESET_DATE.TITLE" = "Course Dates"; diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index dabfab196..9d0340bf9 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -1712,6 +1712,35 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { return __value } + open func getCourseDeadlineInfo(courseID: String) throws -> CourseDateBanner { + addInvocation(.m_getCourseDeadlineInfo__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_getCourseDeadlineInfo__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + var __value: CourseDateBanner + do { + __value = try methodReturnValue(.m_getCourseDeadlineInfo__courseID_courseID(Parameter.value(`courseID`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for getCourseDeadlineInfo(courseID: String). Use given") + Failure("Stub return value not specified for getCourseDeadlineInfo(courseID: String). Use given") + } catch { + throw error + } + return __value + } + + open func shiftDueDates(courseID: String) throws { + addInvocation(.m_shiftDueDates__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_shiftDueDates__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + do { + _ = try methodReturnValue(.m_shiftDueDates__courseID_courseID(Parameter.value(`courseID`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + fileprivate enum MethodType { case m_getCourseBlocks__courseID_courseID(Parameter) @@ -1723,6 +1752,8 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { case m_resumeBlock__courseID_courseID(Parameter) case m_getSubtitles__url_urlselectedLanguage_selectedLanguage(Parameter, Parameter) case m_getCourseDates__courseID_courseID(Parameter) + case m_getCourseDeadlineInfo__courseID_courseID(Parameter) + case m_shiftDueDates__courseID_courseID(Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { @@ -1772,6 +1803,16 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) return Matcher.ComparisonResult(results) + + case (.m_getCourseDeadlineInfo__courseID_courseID(let lhsCourseid), .m_getCourseDeadlineInfo__courseID_courseID(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + return Matcher.ComparisonResult(results) + + case (.m_shiftDueDates__courseID_courseID(let lhsCourseid), .m_shiftDueDates__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 } } @@ -1787,6 +1828,8 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { 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 + case let .m_getCourseDeadlineInfo__courseID_courseID(p0): return p0.intValue + case let .m_shiftDueDates__courseID_courseID(p0): return p0.intValue } } func assertionName() -> String { @@ -1800,6 +1843,8 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { 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:)" + case .m_getCourseDeadlineInfo__courseID_courseID: return ".getCourseDeadlineInfo(courseID:)" + case .m_shiftDueDates__courseID_courseID: return ".shiftDueDates(courseID:)" } } } @@ -1837,6 +1882,9 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { 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 getCourseDeadlineInfo(courseID: Parameter, willReturn: CourseDateBanner...) -> MethodStub { + return Given(method: .m_getCourseDeadlineInfo__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) })) }() @@ -1924,6 +1972,26 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { willProduce(stubber) return given } + public static func getCourseDeadlineInfo(courseID: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_getCourseDeadlineInfo__courseID_courseID(`courseID`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getCourseDeadlineInfo(courseID: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_getCourseDeadlineInfo__courseID_courseID(`courseID`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (CourseDateBanner).self) + willProduce(stubber) + return given + } + public static func shiftDueDates(courseID: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_shiftDueDates__courseID_courseID(`courseID`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func shiftDueDates(courseID: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_shiftDueDates__courseID_courseID(`courseID`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } } public struct Verify { @@ -1938,6 +2006,8 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { 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 static func getCourseDeadlineInfo(courseID: Parameter) -> Verify { return Verify(method: .m_getCourseDeadlineInfo__courseID_courseID(`courseID`))} + public static func shiftDueDates(courseID: Parameter) -> Verify { return Verify(method: .m_shiftDueDates__courseID_courseID(`courseID`))} } public struct Perform { @@ -1971,6 +2041,12 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { public static func getCourseDates(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_getCourseDates__courseID_courseID(`courseID`), performs: perform) } + public static func getCourseDeadlineInfo(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getCourseDeadlineInfo__courseID_courseID(`courseID`), performs: perform) + } + public static func shiftDueDates(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_shiftDueDates__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 index 797c27440..765b8a763 100644 --- a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift @@ -24,7 +24,8 @@ final class CourseDateViewModelTests: XCTestCase { missedDeadlines: false, contentTypeGatingEnabled: false, missedGatedContent: false, - verifiedUpgradeLink: ""), + verifiedUpgradeLink: "", + status: .resetDatesBanner), courseDateBlocks: [], hasEnded: false, learnerIsFullAccess: false, @@ -131,7 +132,8 @@ final class CourseDateViewModelTests: XCTestCase { missedDeadlines: false, contentTypeGatingEnabled: false, missedGatedContent: false, - verifiedUpgradeLink: nil + verifiedUpgradeLink: nil, + status: .resetDatesBanner ), courseDateBlocks: [block1, block2], hasEnded: false, @@ -178,7 +180,8 @@ final class CourseDateViewModelTests: XCTestCase { missedDeadlines: false, contentTypeGatingEnabled: false, missedGatedContent: false, - verifiedUpgradeLink: nil + verifiedUpgradeLink: nil, + status: .resetDatesBanner ), courseDateBlocks: [block1, block2], hasEnded: false, @@ -218,7 +221,7 @@ final class CourseDateViewModelTests: XCTestCase { learnerHasAccess: false, link: "www.example.com", linkText: nil, - title: CoreLocalization.CourseDates.today, + title: CourseLocalization.CourseDates.today, extraInfo: nil, firstComponentBlockID: "blockIDTest" ) diff --git a/Dashboard/Dashboard.xcodeproj/project.pbxproj b/Dashboard/Dashboard.xcodeproj/project.pbxproj index 9eb3ade8c..2a7b812fe 100644 --- a/Dashboard/Dashboard.xcodeproj/project.pbxproj +++ b/Dashboard/Dashboard.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ 02F6EF4328D9ECC500835477 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 02F6EF4528D9ECC500835477 /* Localizable.strings */; }; 02F6EF4828D9ED8300835477 /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F6EF4728D9ED8300835477 /* Strings.swift */; }; 214DA1AADABC7BF4FB8EA1D7 /* Pods_App_Dashboard.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0B008B2F0762EF35CADE3DD4 /* Pods_App_Dashboard.framework */; }; + 97E7DF0B2B7A3EAF00A2A09B /* CourseEnrollmentsMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97E7DF0A2B7A3EAF00A2A09B /* CourseEnrollmentsMock.swift */; }; 9AD4A6A1AAF97092CF457FE2 /* Pods_App_Dashboard_DashboardTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 22905947A936093AD23D4CF8 /* Pods_App_Dashboard_DashboardTests.framework */; }; /* End PBXBuildFile section */ @@ -68,6 +69,7 @@ 65CD5AB152F3DEC88D48AE2D /* Pods-App-Dashboard.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Dashboard.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Dashboard/Pods-App-Dashboard.debugdev.xcconfig"; sourceTree = ""; }; 6BA3D1943CAB859ECDC95C76 /* Pods-App-Dashboard.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Dashboard.releasedev.xcconfig"; path = "Target Support Files/Pods-App-Dashboard/Pods-App-Dashboard.releasedev.xcconfig"; sourceTree = ""; }; 89D6F05AC9854DBBAB091D9C /* Pods-App-Dashboard.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Dashboard.release.xcconfig"; path = "Target Support Files/Pods-App-Dashboard/Pods-App-Dashboard.release.xcconfig"; sourceTree = ""; }; + 97E7DF0A2B7A3EAF00A2A09B /* CourseEnrollmentsMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseEnrollmentsMock.swift; sourceTree = ""; }; BBABB135366FFB1DAEFA0D16 /* Pods-App-Dashboard-DashboardTests.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Dashboard-DashboardTests.debugprod.xcconfig"; path = "Target Support Files/Pods-App-Dashboard-DashboardTests/Pods-App-Dashboard-DashboardTests.debugprod.xcconfig"; sourceTree = ""; }; CCF4C665AD91B6B96F6A11DF /* Pods-App-Dashboard-DashboardTests.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Dashboard-DashboardTests.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Dashboard-DashboardTests/Pods-App-Dashboard-DashboardTests.debugdev.xcconfig"; sourceTree = ""; }; DE6CF4F983BBF52606807F9A /* Pods-App-Dashboard-DashboardTests.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Dashboard-DashboardTests.debugstage.xcconfig"; path = "Target Support Files/Pods-App-Dashboard-DashboardTests/Pods-App-Dashboard-DashboardTests.debugstage.xcconfig"; sourceTree = ""; }; @@ -128,6 +130,7 @@ children = ( 0208666929CC6D0F00BC05B2 /* Persistence */, 027DB33728D8D9E3002B6862 /* Network */, + 97E7DF092B7A3E7200A2A09B /* Mock */, 027DB33C28D8DB5E002B6862 /* DashboardRepository.swift */, ); path = Data; @@ -236,6 +239,14 @@ path = ../Pods; sourceTree = ""; }; + 97E7DF092B7A3E7200A2A09B /* Mock */ = { + isa = PBXGroup; + children = ( + 97E7DF0A2B7A3EAF00A2A09B /* CourseEnrollmentsMock.swift */, + ); + path = Mock; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -452,6 +463,7 @@ 02F3BFE129252FCB0051930C /* DashboardRouter.swift in Sources */, 027DB33928D8D9F6002B6862 /* DashboardEndpoint.swift in Sources */, 02F6EF4828D9ED8300835477 /* Strings.swift in Sources */, + 97E7DF0B2B7A3EAF00A2A09B /* CourseEnrollmentsMock.swift in Sources */, 027DB34328D8E89B002B6862 /* DashboardInteractor.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Dashboard/Dashboard/Data/DashboardRepository.swift b/Dashboard/Dashboard/Data/DashboardRepository.swift index cff780083..65816872f 100644 --- a/Dashboard/Dashboard/Data/DashboardRepository.swift +++ b/Dashboard/Dashboard/Data/DashboardRepository.swift @@ -47,6 +47,18 @@ public class DashboardRepository: DashboardRepositoryProtocol { // Mark - For testing and SwiftUI preview #if DEBUG class DashboardRepositoryMock: DashboardRepositoryProtocol { + func getCourseEnrollments(baseURL: String) async throws -> [CourseItem] { + do { + let courseEnrollments = try + DashboardRepository.CourseEnrollmentsJSON.data(using: .utf8)! + .mapResponse(DataLayer.CourseEnrollments.self) + .domain(baseURL: baseURL) + return courseEnrollments + } catch { + throw error + } + } + func getMyCourses(page: Int) async throws -> [CourseItem] { var models: [CourseItem] = [] for i in 0...10 { diff --git a/Dashboard/Dashboard/Data/Mock/CourseEnrollmentsMock.swift b/Dashboard/Dashboard/Data/Mock/CourseEnrollmentsMock.swift new file mode 100644 index 000000000..ab9b75d25 --- /dev/null +++ b/Dashboard/Dashboard/Data/Mock/CourseEnrollmentsMock.swift @@ -0,0 +1,612 @@ +// +// CourseEnrollmentsMock.swift +// Dashboard +// +// Created by Shafqat Muneer on 2/12/24. +// + +import Foundation + +// Mark - For testing and SwiftUI preview +// swiftlint:disable all +#if DEBUG +extension DashboardRepository { + static let CourseEnrollmentsJSON: String = """ + { + "enrollments": { + "next": null, + "previous": null, + "count": 114, + "num_pages": 1, + "current_page": 1, + "start": 0, + "results": [ + { + "audit_access_expires": "2024-03-05T05:23:03Z", + "created": "2024-02-06T05:23:03Z", + "mode": "audit", + "is_active": true, + "course": { + "id": "course-v1:ZHAWx+SCF1+1T2024", + "name": "Sustainable Corporate Financing: Foundations", + "number": "1", + "org": "ZHAWx", + "start": "2024-02-05T11:00:00Z", + "start_display": "Feb. 5, 2024", + "start_type": "timestamp", + "end": "2024-07-07T10:00:00Z", + "dynamic_upgrade_deadline": "2024-06-27T23:59:59Z", + "subscription_id": "course_MNXXK4TTMUWXMMJ2LJEECV3YFNJUGRRRFMYVIMRQGI2A____", + "courseware_access": { + "has_access": true, + "error_code": null, + "developer_message": null, + "user_message": null, + "additional_context_user_message": null, + "user_fragment": null + }, + "media": { + "course_image": { + "uri": "/asset-v1:ZHAWx+SCF1+1T2024+type@asset+block@course_image.png", + "name": "Course Image" + } + }, + "course_image": "/asset-v1:ZHAWx+SCF1+1T2024+type@asset+block@course_image.png", + "course_about": "https://www.edx.org/course/sustainable-corporate-financing-foundations-course-v1-zhawx-scf1-1t2024", + "course_sharing_utm_parameters": { + "facebook": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=facebook", + "twitter": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=twitter" + }, + "course_updates": "https://courses.edx.org/api/mobile/v3/course_info/course-v1:ZHAWx+SCF1+1T2024/updates", + "course_handouts": "https://courses.edx.org/api/mobile/v3/course_info/course-v1:ZHAWx+SCF1+1T2024/handouts", + "discussion_url": "https://courses.edx.org/api/discussion/v1/courses/course-v1:ZHAWx+SCF1+1T2024", + "video_outline": null, + "is_self_paced": false + }, + "certificate": { + + }, + "course_modes": [ + { + "slug": "audit", + "sku": "08D0410", + "android_sku": null, + "ios_sku": null + }, + { + "slug": "verified", + "sku": "6D7CAF1", + "android_sku": null, + "ios_sku": null + } + ] + }, + { + "audit_access_expires": "2024-03-01T11:40:45Z", + "created": "2024-02-02T11:40:45Z", + "mode": "audit", + "is_active": true, + "course": { + "id": "course-v1:ZHAWx+SCF2+2T2023", + "name": "Sustainable Corporate Financing: Application", + "number": "1", + "org": "ZHAWx", + "start": "2023-08-07T10:00:00Z", + "start_display": "Aug. 7, 2023", + "start_type": "timestamp", + "end": "2024-02-04T23:59:00Z", + "dynamic_upgrade_deadline": null, + "subscription_id": "course_MNXXK4TTMUWXMMJ2LJEECV3YFNJUGRRSFMZFIMRQGIZQ____", + "courseware_access": { + "has_access": true, + "error_code": null, + "developer_message": null, + "user_message": null, + "additional_context_user_message": null, + "user_fragment": null + }, + "media": { + "course_image": { + "uri": "/asset-v1:ZHAWx+SCF2+2T2023+type@asset+block@course_image.png", + "name": "Course Image" + } + }, + "course_image": "/asset-v1:ZHAWx+SCF2+2T2023+type@asset+block@course_image.png", + "course_about": "https://www.edx.org/course/sustainable-corporate-financing-application-course-v1-zhawx-scf2-2t2023", + "course_sharing_utm_parameters": { + "facebook": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=facebook", + "twitter": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=twitter" + }, + "course_updates": "https://courses.edx.org/api/mobile/v3/course_info/course-v1:ZHAWx+SCF2+2T2023/updates", + "course_handouts": "https://courses.edx.org/api/mobile/v3/course_info/course-v1:ZHAWx+SCF2+2T2023/handouts", + "discussion_url": "https://courses.edx.org/api/discussion/v1/courses/course-v1:ZHAWx+SCF2+2T2023", + "video_outline": null, + "is_self_paced": false + }, + "certificate": { + + }, + "course_modes": [ + { + "slug": "audit", + "sku": "C748313", + "android_sku": null, + "ios_sku": null + } + ] + }, + { + "audit_access_expires": "2024-03-29T11:40:07Z", + "created": "2024-02-02T11:40:07Z", + "mode": "audit", + "is_active": true, + "course": { + "id": "course-v1:FedericaX+50+3T2020a", + "name": "Robotics Foundations I - Robot Modeling", + "number": "50", + "org": "FedericaX", + "start": "2020-10-19T10:00:00Z", + "start_display": "Oct. 19, 2020", + "start_type": "timestamp", + "end": "2024-12-31T10:00:00Z", + "dynamic_upgrade_deadline": "2024-12-21T23:59:59Z", + "subscription_id": "course_MNXXK4TTMUWXMMJ2IZSWIZLSNFRWCWBLGUYCWM2UGIYDEMDB", + "courseware_access": { + "has_access": true, + "error_code": null, + "developer_message": null, + "user_message": null, + "additional_context_user_message": null, + "user_fragment": null + }, + "media": { + "course_image": { + "uri": "/asset-v1:FedericaX+50+3T2020a+type@asset+block@course_image.jpg", + "name": "Course Image" + } + }, + "course_image": "/asset-v1:FedericaX+50+3T2020a+type@asset+block@course_image.jpg", + "course_about": "https://www.edx.org/course/robotics-foundations-i-robot-modeling-course-v1federicax503t2020a", + "course_sharing_utm_parameters": { + "facebook": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=facebook", + "twitter": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=twitter" + }, + "course_updates": "https://courses.edx.org/api/mobile/v3/course_info/course-v1:FedericaX+50+3T2020a/updates", + "course_handouts": "https://courses.edx.org/api/mobile/v3/course_info/course-v1:FedericaX+50+3T2020a/handouts", + "discussion_url": "https://courses.edx.org/api/discussion/v1/courses/course-v1:FedericaX+50+3T2020a", + "video_outline": null, + "is_self_paced": false + }, + "certificate": { + + }, + "course_modes": [ + { + "slug": "audit", + "sku": "F84F145", + "android_sku": null, + "ios_sku": null + }, + { + "slug": "verified", + "sku": "7B00BCC", + "android_sku": "mobile.android.7b00bcc", + "ios_sku": "mobile.ios.7b00bcc" + } + ] + }, + { + "audit_access_expires": "2024-02-26T15:05:37Z", + "created": "2024-01-29T15:05:37Z", + "mode": "audit", + "is_active": true, + "course": { + "id": "course-v1:ZHAWx+SCF1+2T2023", + "name": "Sustainable Corporate Financing: Foundations", + "number": "1", + "org": "ZHAWx", + "start": "2023-08-07T10:00:00Z", + "start_display": "Aug. 7, 2023", + "start_type": "timestamp", + "end": "2024-02-04T23:59:00Z", + "dynamic_upgrade_deadline": null, + "subscription_id": "course_MNXXK4TTMUWXMMJ2LJEECV3YFNJUGRRRFMZFIMRQGIZQ____", + "courseware_access": { + "has_access": true, + "error_code": null, + "developer_message": null, + "user_message": null, + "additional_context_user_message": null, + "user_fragment": null + }, + "media": { + "course_image": { + "uri": "/asset-v1:ZHAWx+SCF1+2T2023+type@asset+block@course_image.png", + "name": "Course Image" + } + }, + "course_image": "/asset-v1:ZHAWx+SCF1+2T2023+type@asset+block@course_image.png", + "course_about": "https://www.edx.org/course/sustainable-corporate-financing-foundations-course-v1-zhawx-scf1-2t2023", + "course_sharing_utm_parameters": { + "facebook": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=facebook", + "twitter": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=twitter" + }, + "course_updates": "https://courses.edx.org/api/mobile/v3/course_info/course-v1:ZHAWx+SCF1+2T2023/updates", + "course_handouts": "https://courses.edx.org/api/mobile/v3/course_info/course-v1:ZHAWx+SCF1+2T2023/handouts", + "discussion_url": "https://courses.edx.org/api/discussion/v1/courses/course-v1:ZHAWx+SCF1+2T2023", + "video_outline": null, + "is_self_paced": false + }, + "certificate": { + + }, + "course_modes": [ + { + "slug": "audit", + "sku": "E059A8E", + "android_sku": null, + "ios_sku": null + } + ] + }, + { + "audit_access_expires": "2024-02-21T09:08:25Z", + "created": "2024-01-24T09:08:25Z", + "mode": "audit", + "is_active": true, + "course": { + "id": "course-v1:USMx+PGPM661.4x+2T2022", + "name": "Creating an Organizational Change Management Framework - Transforming Strategy Execution to Realize Program Value", + "number": "PGPM661.4x", + "org": "USMx", + "start": "2022-11-01T16:00:00Z", + "start_display": "Nov. 1, 2022", + "start_type": "timestamp", + "end": "2024-07-31T16:00:00Z", + "dynamic_upgrade_deadline": "2024-07-21T23:59:59Z", + "subscription_id": "course_MNXXK4TTMUWXMMJ2KVJU26BLKBDVATJWGYYS4NDYFMZFIMRQGIZA____", + "courseware_access": { + "has_access": true, + "error_code": null, + "developer_message": null, + "user_message": null, + "additional_context_user_message": null, + "user_fragment": null + }, + "media": { + "course_image": { + "uri": "/asset-v1:USMx+PGPM661.4x+2T2022+type@asset+block@course_image.png", + "name": "Course Image" + } + }, + "course_image": "/asset-v1:USMx+PGPM661.4x+2T2022+type@asset+block@course_image.png", + "course_about": "https://www.edx.org/course/benefits-realization-management-brm-linking-strategy-execution-to-achieving-value-course-v1usmxpgpm6614x2t2022", + "course_sharing_utm_parameters": { + "facebook": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=facebook", + "twitter": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=twitter" + }, + "course_updates": "https://courses.edx.org/api/mobile/v3/course_info/course-v1:USMx+PGPM661.4x+2T2022/updates", + "course_handouts": "https://courses.edx.org/api/mobile/v3/course_info/course-v1:USMx+PGPM661.4x+2T2022/handouts", + "discussion_url": "https://courses.edx.org/api/discussion/v1/courses/course-v1:USMx+PGPM661.4x+2T2022", + "video_outline": null, + "is_self_paced": false + }, + "certificate": { + + }, + "course_modes": [ + { + "slug": "audit", + "sku": "C08ED36", + "android_sku": null, + "ios_sku": null + }, + { + "slug": "verified", + "sku": "10E91CB", + "android_sku": null, + "ios_sku": null + } + ] + }, + { + "audit_access_expires": "2024-02-21T09:08:05Z", + "created": "2024-01-24T09:08:05Z", + "mode": "audit", + "is_active": true, + "course": { + "id": "course-v1:USMx+PGPM661.3x+2T2022", + "name": "The Program Management Office (PMO) - The Strategy Execution Arm", + "number": "PGPM661.3x", + "org": "USMx", + "start": "2022-07-13T16:30:00Z", + "start_display": "July 13, 2022", + "start_type": "timestamp", + "end": "2024-07-31T16:00:00Z", + "dynamic_upgrade_deadline": "2024-07-21T23:59:59Z", + "subscription_id": "course_MNXXK4TTMUWXMMJ2KVJU26BLKBDVATJWGYYS4M3YFMZFIMRQGIZA____", + "courseware_access": { + "has_access": true, + "error_code": null, + "developer_message": null, + "user_message": null, + "additional_context_user_message": null, + "user_fragment": null + }, + "media": { + "course_image": { + "uri": "/asset-v1:USMx+PGPM661.3x+2T2022+type@asset+block@course_image.png", + "name": "Course Image" + } + }, + "course_image": "/asset-v1:USMx+PGPM661.3x+2T2022+type@asset+block@course_image.png", + "course_about": "https://www.edx.org/course/the-program-management-office-pmo-the-strategy-execution-arm-course-v1usmxpgpm6613x2t2022", + "course_sharing_utm_parameters": { + "facebook": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=facebook", + "twitter": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=twitter" + }, + "course_updates": "https://courses.edx.org/api/mobile/v3/course_info/course-v1:USMx+PGPM661.3x+2T2022/updates", + "course_handouts": "https://courses.edx.org/api/mobile/v3/course_info/course-v1:USMx+PGPM661.3x+2T2022/handouts", + "discussion_url": "https://courses.edx.org/api/discussion/v1/courses/course-v1:USMx+PGPM661.3x+2T2022", + "video_outline": null, + "is_self_paced": false + }, + "certificate": { + + }, + "course_modes": [ + { + "slug": "audit", + "sku": "22F61B0", + "android_sku": null, + "ios_sku": null + }, + { + "slug": "verified", + "sku": "28D953F", + "android_sku": null, + "ios_sku": null + } + ] + }, + { + "audit_access_expires": "2024-05-27T23:59:00Z", + "created": "2024-01-23T07:45:17Z", + "mode": "audit", + "is_active": true, + "course": { + "id": "course-v1:MITx+18.6501x+1T2024", + "name": "Fundamentals of Statistics", + "number": "18.6501x", + "org": "MITx", + "start": "2024-01-29T23:59:00Z", + "start_display": "January 29, 2024", + "start_type": "string", + "end": "2024-05-25T23:59:00Z", + "dynamic_upgrade_deadline": "2024-03-29T12:00:00Z", + "subscription_id": "course_MNXXK4TTMUWXMMJ2JVEVI6BLGE4C4NRVGAYXQKZRKQZDAMRU", + "courseware_access": { + "has_access": true, + "error_code": null, + "developer_message": null, + "user_message": null, + "additional_context_user_message": null, + "user_fragment": null + }, + "media": { + "course_image": { + "uri": "/asset-v1:MITx+18.6501x+1T2024+type@asset+block@course_image.jpg", + "name": "Course Image" + } + }, + "course_image": "/asset-v1:MITx+18.6501x+1T2024+type@asset+block@course_image.jpg", + "course_about": "https://www.edx.org/course/fundamentals-of-statistics-course-v1-mitx-18-6501x-1t2024", + "course_sharing_utm_parameters": { + "facebook": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=facebook", + "twitter": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=twitter" + }, + "course_updates": "https://courses.edx.org/api/mobile/v3/course_info/course-v1:MITx+18.6501x+1T2024/updates", + "course_handouts": "https://courses.edx.org/api/mobile/v3/course_info/course-v1:MITx+18.6501x+1T2024/handouts", + "discussion_url": "https://courses.edx.org/api/discussion/v1/courses/course-v1:MITx+18.6501x+1T2024", + "video_outline": null, + "is_self_paced": false + }, + "certificate": { + + }, + "course_modes": [ + { + "slug": "audit", + "sku": "4FACA1A", + "android_sku": null, + "ios_sku": null + }, + { + "slug": "verified", + "sku": "85B9FCB", + "android_sku": "mobile.android.85b9fcb", + "ios_sku": "mobile.ios.85b9fcb" + } + ] + }, + { + "audit_access_expires": "2024-05-20T23:59:00Z", + "created": "2024-01-23T07:35:11Z", + "mode": "audit", + "is_active": true, + "course": { + "id": "course-v1:MITx+6.431x+1T2024", + "name": "Probability - The Science of Uncertainty and Data", + "number": "6.431x", + "org": "MITx", + "start": "2024-01-29T23:59:00Z", + "start_display": "Jan. 29, 2024", + "start_type": "timestamp", + "end": "2024-05-23T23:59:00Z", + "dynamic_upgrade_deadline": "2024-03-14T12:00:00Z", + "subscription_id": "course_MNXXK4TTMUWXMMJ2JVEVI6BLGYXDIMZRPAVTCVBSGAZDI___", + "courseware_access": { + "has_access": true, + "error_code": null, + "developer_message": null, + "user_message": null, + "additional_context_user_message": null, + "user_fragment": null + }, + "media": { + "course_image": { + "uri": "/asset-v1:MITx+6.431x+1T2024+type@asset+block@course_image.jpg", + "name": "Course Image" + } + }, + "course_image": "/asset-v1:MITx+6.431x+1T2024+type@asset+block@course_image.jpg", + "course_about": "https://www.edx.org/course/probability-the-science-of-uncertainty-and-data-course-v1-mitx-6-431x-1t2024", + "course_sharing_utm_parameters": { + "facebook": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=facebook", + "twitter": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=twitter" + }, + "course_updates": "https://courses.edx.org/api/mobile/v3/course_info/course-v1:MITx+6.431x+1T2024/updates", + "course_handouts": "https://courses.edx.org/api/mobile/v3/course_info/course-v1:MITx+6.431x+1T2024/handouts", + "discussion_url": "https://courses.edx.org/api/discussion/v1/courses/course-v1:MITx+6.431x+1T2024", + "video_outline": null, + "is_self_paced": false + }, + "certificate": { + + }, + "course_modes": [ + { + "slug": "audit", + "sku": "4273238", + "android_sku": null, + "ios_sku": null + }, + { + "slug": "verified", + "sku": "403F90D", + "android_sku": "mobile.android.403f90d", + "ios_sku": "mobile.ios.403f90d" + } + ] + }, + { + "audit_access_expires": "2024-09-09T15:00:00Z", + "created": "2024-01-20T00:02:13Z", + "mode": "audit", + "is_active": true, + "course": { + "id": "course-v1:MITx+6.86x+2T2024", + "name": "Machine Learning with Python: from Linear Models to Deep Learning", + "number": "6.86x", + "org": "MITx", + "start": "2024-05-27T15:00:00Z", + "start_display": "May 27, 2024", + "start_type": "timestamp", + "end": "2024-09-03T15:00:00Z", + "dynamic_upgrade_deadline": "2024-08-24T23:59:59Z", + "subscription_id": "course_MNXXK4TTMUWXMMJ2JVEVI6BLGYXDQNTYFMZFIMRQGI2A____", + "courseware_access": { + "has_access": false, + "error_code": "course_not_started", + "developer_message": "Course does not start until 2024-05-27 15:00:00+00:00", + "user_message": "Course does not start until May 27, 2024", + "additional_context_user_message": null, + "user_fragment": null + }, + "media": { + "course_image": { + "uri": "/asset-v1:MITx+6.86x+2T2024+type@asset+block@course_image.jpg", + "name": "Course Image" + } + }, + "course_image": "/asset-v1:MITx+6.86x+2T2024+type@asset+block@course_image.jpg", + "course_about": "https://www.edx.org/course/machine-learning-with-python-from-linear-models-to-deep-learning-course-v1-mitx-6-86x-2t2024", + "course_sharing_utm_parameters": { + "facebook": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=facebook", + "twitter": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=twitter" + }, + "course_updates": "https://courses.edx.org/api/mobile/v3/course_info/course-v1:MITx+6.86x+2T2024/updates", + "course_handouts": "https://courses.edx.org/api/mobile/v3/course_info/course-v1:MITx+6.86x+2T2024/handouts", + "discussion_url": "https://courses.edx.org/api/discussion/v1/courses/course-v1:MITx+6.86x+2T2024", + "video_outline": null, + "is_self_paced": false + }, + "certificate": { + + }, + "course_modes": [ + { + "slug": "audit", + "sku": "A962046", + "android_sku": null, + "ios_sku": null + }, + { + "slug": "verified", + "sku": "AD41871", + "android_sku": "mobile.android.ad41871", + "ios_sku": "mobile.ios.ad41871" + } + ] + }, + { + "audit_access_expires": null, + "created": "2024-01-20T00:00:20Z", + "mode": "audit", + "is_active": true, + "course": { + "id": "course-v1:edX+VideoX+1T2020", + "name": "VideoX: Creating Video for the edX Platform", + "number": "VideoX", + "org": "edX", + "start": "2020-02-15T17:00:00Z", + "start_display": "Feb. 15, 2020", + "start_type": "timestamp", + "end": "2022-02-03T23:59:00Z", + "dynamic_upgrade_deadline": null, + "subscription_id": "course_MNXXK4TTMUWXMMJ2MVSFQK2WNFSGK32YFMYVIMRQGIYA____", + "courseware_access": { + "has_access": true, + "error_code": null, + "developer_message": null, + "user_message": null, + "additional_context_user_message": null, + "user_fragment": null + }, + "media": { + "course_image": { + "uri": "/asset-v1:edX+VideoX+1T2020+type@asset+block@course_image.jpg", + "name": "Course Image" + } + }, + "course_image": "/asset-v1:edX+VideoX+1T2020+type@asset+block@course_image.jpg", + "course_about": "https://www.edx.org/course/videox-creating-video-for-the-edx-platform-3", + "course_sharing_utm_parameters": { + "facebook": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=facebook", + "twitter": "utm_medium=social&utm_campaign=social-sharing-db&utm_source=twitter" + }, + "course_updates": "https://courses.edx.org/api/mobile/v3/course_info/course-v1:edX+VideoX+1T2020/updates", + "course_handouts": "https://courses.edx.org/api/mobile/v3/course_info/course-v1:edX+VideoX+1T2020/handouts", + "discussion_url": "https://courses.edx.org/api/discussion/v1/courses/course-v1:edX+VideoX+1T2020", + "video_outline": null, + "is_self_paced": false + }, + "certificate": { + + }, + "course_modes": [ + { + "slug": "audit", + "sku": "99D8FF6", + "android_sku": null, + "ios_sku": null + } + ] + } + ] + } + } + """ +} +#endif +// swiftlint:enable all diff --git a/Theme/Theme/SwiftGen/ThemeAssets.swift b/Theme/Theme/SwiftGen/ThemeAssets.swift index 09afbbb6b..19d67ddf2 100644 --- a/Theme/Theme/SwiftGen/ThemeAssets.swift +++ b/Theme/Theme/SwiftGen/ThemeAssets.swift @@ -38,6 +38,7 @@ public enum ThemeAssets { public static let commentCellBackground = ColorAsset(name: "CommentCellBackground") public static let loginBackground = ColorAsset(name: "LoginBackground") public static let loginNavigationText = ColorAsset(name: "LoginNavigationText") + public static let secondaryButtonBorderColor = ColorAsset(name: "SecondaryButtonBorderColor") public static let secondaryButtonTextColor = ColorAsset(name: "SecondaryButtonTextColor") public static let shadowColor = ColorAsset(name: "ShadowColor") public static let snackbarErrorColor = ColorAsset(name: "SnackbarErrorColor") @@ -55,7 +56,6 @@ public enum ThemeAssets { public static let textInputUnfocusedBackground = ColorAsset(name: "TextInputUnfocusedBackground") public static let textInputUnfocusedStroke = ColorAsset(name: "TextInputUnfocusedStroke") public static let navigationBarTintColor = ColorAsset(name: "navigationBarTintColor") - public static let secondaryButtonBorderColor = ColorAsset(name: "secondaryButtonBorderColor") public static let warning = ColorAsset(name: "warning") public static let white = ColorAsset(name: "white") public static let datesSectionBackground = ColorAsset(name: "DatesSectionBackground") From a36ebdbc1b99d0269d4525d5b0ada8966a80912d Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Fri, 1 Mar 2024 17:34:12 +0500 Subject: [PATCH 044/136] refactor: color combination improvements for light dark modes --- .../Authorization/Presentation/Startup/StartupView.swift | 2 +- Core/Core/Extensions/UIApplicationExtension.swift | 4 ++-- Core/Core/View/Base/CheckBoxView.swift | 2 +- Core/Core/View/Base/PickerMenu.swift | 1 - .../Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift | 2 +- .../CourseStructure/CourseStructureNestedListView.swift | 4 ++-- OpenEdX/View/MainScreenView.swift | 2 +- .../Presentation/DeleteAccount/DeleteAccountView.swift | 8 ++++---- Profile/Profile/Presentation/Profile/ProfileView.swift | 4 ++-- .../Profile/UserProfile/UserProfileView.swift | 1 + 10 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Authorization/Authorization/Presentation/Startup/StartupView.swift b/Authorization/Authorization/Presentation/Startup/StartupView.swift index d68e97c62..bf5313947 100644 --- a/Authorization/Authorization/Presentation/Startup/StartupView.swift +++ b/Authorization/Authorization/Presentation/Startup/StartupView.swift @@ -89,7 +89,7 @@ public struct StartupView: View { } label: { Text(AuthLocalization.Startup.exploreAllCourses) .underline() - .foregroundColor(Theme.Colors.accentColor) + .foregroundColor(Theme.Colors.accentXColor) .font(Theme.Fonts.bodyLarge) } .padding(.top, isHorizontal ? 0 : 5) diff --git a/Core/Core/Extensions/UIApplicationExtension.swift b/Core/Core/Extensions/UIApplicationExtension.swift index 39dfa7692..3f1eaaf3e 100644 --- a/Core/Core/Extensions/UIApplicationExtension.swift +++ b/Core/Core/Extensions/UIApplicationExtension.swift @@ -59,9 +59,9 @@ extension UINavigationController { .font: Theme.UIFonts.labelLarge() ], for: .normal) - UISegmentedControl.appearance().selectedSegmentTintColor = UIColor(Theme.Colors.accentColor) + UISegmentedControl.appearance().selectedSegmentTintColor = UIColor(Theme.Colors.accentXColor) - UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = Theme.UIColors.accentColor + UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = Theme.UIColors.accentXColor } } diff --git a/Core/Core/View/Base/CheckBoxView.swift b/Core/Core/View/Base/CheckBoxView.swift index a900d292a..efed96ccc 100644 --- a/Core/Core/View/Base/CheckBoxView.swift +++ b/Core/Core/View/Base/CheckBoxView.swift @@ -26,7 +26,7 @@ public struct CheckBoxView: View { systemName: checked ? "checkmark.square.fill" : "square" ) .foregroundColor( - checked ? Theme.Colors.accentColor : Theme.Colors.textPrimary + checked ? Theme.Colors.accentXColor : Theme.Colors.textPrimary ) Text(text) .font(font) diff --git a/Core/Core/View/Base/PickerMenu.swift b/Core/Core/View/Base/PickerMenu.swift index 9bacdbe65..2066a8aa0 100644 --- a/Core/Core/View/Base/PickerMenu.swift +++ b/Core/Core/View/Base/PickerMenu.swift @@ -88,7 +88,6 @@ public struct PickerMenu: View { .font(Theme.Fonts.bodySmall) .background(Theme.Colors.textInputStroke.cornerRadius(6)) .accessibilityIdentifier("picker_search_textfield") - .foregroundColor(Theme.Colors.textPrimary) Picker("", selection: $selectedItem) { ForEach(filteredItems, id: \.self) { item in Text(item.value) diff --git a/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift index 29401776f..129a5f6b9 100644 --- a/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift +++ b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift @@ -162,7 +162,7 @@ extension ScrollSlidingTabBar { public static let `default` = Style( font: Theme.Fonts.bodyLarge, selectedFont: Theme.Fonts.titleMedium, - activeAccentColor: Theme.Colors.accentColor, + activeAccentColor: Theme.Colors.accentXColor, inactiveAccentColor: Theme.Colors.textSecondary, indicatorHeight: 2, borderColor: .gray.opacity(0.2), diff --git a/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift b/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift index a868c19fa..e057b3b7c 100644 --- a/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift +++ b/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift @@ -50,8 +50,8 @@ struct CourseStructureNestedListView: View { .lineLimit(1) .foregroundColor(Theme.Colors.textPrimary) Spacer() - Image(systemName: "chevron.down") - .foregroundColor(Theme.Colors.accentColor) + Image(systemName: "chevron.down").renderingMode(.template) + .foregroundColor(Theme.Colors.accentXColor) .dropdownArrowRotationAnimation(value: isExpanded) } .padding(.horizontal, 30) diff --git a/OpenEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift index fd022b374..03bc5a58b 100644 --- a/OpenEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -175,7 +175,7 @@ struct MainScreenView: View { await viewModel.prefetchDataForOffline() } } - .accentColor(Theme.Colors.accentColor) + .accentColor(Theme.Colors.accentXColor) } private func titleBar() -> String { diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift index 67967fb2f..78da9062a 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift @@ -119,18 +119,18 @@ public struct DeleteAccountView: View { HStack(spacing: 9) { CoreAssets.arrowRight16.swiftUIImage.renderingMode(.template) .rotationEffect(Angle(degrees: 180)) - .foregroundColor(Theme.Colors.accentColor) + .foregroundColor(Theme.Colors.secondaryButtonTextColor) Text(ProfileLocalization.DeleteAccount.backToProfile) .font(Theme.Fonts.labelLarge) - .foregroundColor(Theme.Colors.accentColor) + .foregroundColor(Theme.Colors.secondaryButtonTextColor) } }) - .padding(.top, 35) + .padding(.top, 5) .accessibilityIdentifier("back_button") .frame(maxWidth: .infinity, minHeight: 42) .background( Theme.Shapes.buttonShape - .fill(Theme.Colors.white) + .fill(.clear) ) .overlay( Theme.Shapes.buttonShape diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index 03d313211..1e76e1f22 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -144,7 +144,7 @@ public struct ProfileView: View { if viewModel.userModel?.yearOfBirth != 0 { HStack { Text(ProfileLocalization.Edit.Fields.yearOfBirth) - .foregroundColor(Theme.Colors.textSecondary) + .foregroundColor(Theme.Colors.textPrimary) .accessibilityIdentifier("yob_text") Text(String(viewModel.userModel?.yearOfBirth ?? 0)) .accessibilityIdentifier("yob_value_text") @@ -153,7 +153,7 @@ public struct ProfileView: View { if let bio = viewModel.userModel?.shortBiography, bio != "" { HStack(alignment: .top) { Text(ProfileLocalization.bio + " ") - .foregroundColor(Theme.Colors.textSecondary) + .foregroundColor(Theme.Colors.textPrimary) + Text(bio) } .accessibilityIdentifier("bio_text") diff --git a/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift index 870060a77..905f03681 100644 --- a/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift +++ b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift @@ -50,6 +50,7 @@ public struct UserProfileView: View { Text(ProfileLocalization.info) .padding(.horizontal, 24) .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textSecondary) VStack(alignment: .leading, spacing: 16) { if viewModel.userModel?.yearOfBirth != 0 { From e6f08c1832f9ca88090903c3219e01f80726fa47 Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Mon, 4 Mar 2024 16:44:24 +0500 Subject: [PATCH 045/136] chore: theme improvements --- .../Presentation/Login/SignInView.swift | 2 +- Core/Core/View/Base/AlertView.swift | 6 +-- Core/Core/View/Base/CourseCellView.swift | 2 +- .../View/Base/VideoDownloadQualityView.swift | 4 +- Core/Core/View/Base/WebBrowser.swift | 6 ++- .../WebPrograms/ProgramWebviewView.swift | 3 +- OpenEdX/View/MainScreenView.swift | 4 +- .../DeleteAccount/DeleteAccountView.swift | 1 + .../Presentation/Profile/ProfileView.swift | 2 + .../Subviews/ProfileSupportInfoView.swift | 6 +-- .../Presentation/Settings/SettingsView.swift | 4 +- Theme/Theme.xcodeproj/project.pbxproj | 16 ++++---- .../Contents.json | 38 +++++++++++++++++++ .../Colors/TabbarColor.colorset/Contents.json | 38 +++++++++++++++++++ Theme/Theme/SwiftGen/ThemeAssets.swift | 2 + Theme/Theme/Theme.swift | 8 +++- 16 files changed, 117 insertions(+), 25 deletions(-) create mode 100644 Theme/Theme/Assets.xcassets/Colors/PrimaryButtonTextColor.colorset/Contents.json create mode 100644 Theme/Theme/Assets.xcassets/Colors/TabbarColor.colorset/Contents.json diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index 25592c025..c84a32862 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -128,7 +128,7 @@ public struct SignInView: View { viewModel.router.showForgotPasswordScreen() } .font(Theme.Fonts.bodyMedium) - .foregroundColor(Theme.Colors.accentColor) + .foregroundColor(Theme.Colors.accentXColor) .padding(.top, 0) .accessibilityIdentifier("forgot_password_button") } diff --git a/Core/Core/View/Base/AlertView.swift b/Core/Core/View/Base/AlertView.swift index 021cd6dac..ff79856c4 100644 --- a/Core/Core/View/Base/AlertView.swift +++ b/Core/Core/View/Base/AlertView.swift @@ -268,7 +268,7 @@ public struct AlertView: View { }, label: { ZStack { Text(CoreLocalization.Alert.logout) - .foregroundColor(Theme.Colors.white) + .foregroundColor(Theme.Colors.primaryButtonTextColor) .font(Theme.Fonts.labelLarge) .frame(maxWidth: .infinity) .padding(.horizontal, 16) @@ -300,7 +300,7 @@ public struct AlertView: View { }, label: { ZStack { Text(CoreLocalization.Alert.leave) - .foregroundColor(Theme.Colors.white) + .foregroundColor(Theme.Colors.primaryButtonTextColor) .font(Theme.Fonts.labelLarge) .frame(maxWidth: .infinity) .padding(.horizontal, 16) @@ -359,7 +359,7 @@ public struct AlertView: View { } label: { ZStack { Text(CoreLocalization.Alert.delete) - .foregroundColor(Theme.Colors.white) + .foregroundColor(Theme.Colors.primaryButtonTextColor) .font(Theme.Fonts.labelLarge) .frame(maxWidth: .infinity) .padding(.horizontal, 16) diff --git a/Core/Core/View/Base/CourseCellView.swift b/Core/Core/View/Base/CourseCellView.swift index 2d8ea1c24..6166b81a3 100644 --- a/Core/Core/View/Base/CourseCellView.swift +++ b/Core/Core/View/Base/CourseCellView.swift @@ -85,7 +85,7 @@ public struct CourseCellView: View { .resizable() .frame(width: 16, height: 16) .offset(x: 15) - .foregroundColor(Theme.Colors.accentColor) + .foregroundColor(Theme.Colors.accentXColor) .accessibilityIdentifier("arrow_image") } } diff --git a/Core/Core/View/Base/VideoDownloadQualityView.swift b/Core/Core/View/Base/VideoDownloadQualityView.swift index 6ffa52466..b4bb391c6 100644 --- a/Core/Core/View/Base/VideoDownloadQualityView.swift +++ b/Core/Core/View/Base/VideoDownloadQualityView.swift @@ -108,11 +108,11 @@ public struct SettingsCell: View { public var body: some View { VStack(alignment: .leading) { Text(title) - .font(Theme.Fonts.titleMedium) + .font(Theme.Fonts.labelLarge) .accessibilityIdentifier("video_quality_title_text") if let description { Text(description) - .font(Theme.Fonts.labelMedium) + .font(Theme.Fonts.labelSmall) .foregroundColor(Theme.Colors.textSecondary) .accessibilityIdentifier("video_quality_des_text") } diff --git a/Core/Core/View/Base/WebBrowser.swift b/Core/Core/View/Base/WebBrowser.swift index 776c14733..8f441bacd 100644 --- a/Core/Core/View/Base/WebBrowser.swift +++ b/Core/Core/View/Base/WebBrowser.swift @@ -54,7 +54,11 @@ public struct WebBrowser: View { leftButtonAction: { presentationMode.wrappedValue.dismiss() } ) WebView( - viewModel: .init(url: url, baseURL: ""), + viewModel: .init( + url: url, + baseURL: "", + injections: [.colorInversionCss] + ), isLoading: $isLoading, refreshCookies: {} ) diff --git a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift index d5a1b4f8f..099846af3 100644 --- a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift +++ b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift @@ -58,7 +58,8 @@ public struct ProgramWebviewView: View { WebView( viewModel: .init( url: URLString, - baseURL: "" + baseURL: "", + injections: [.colorInversionCss] ), isLoading: $isLoading, refreshCookies: { diff --git a/OpenEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift index 03bc5a58b..b3895ab1d 100644 --- a/OpenEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -34,8 +34,8 @@ struct MainScreenView: View { init(viewModel: MainScreenViewModel) { self.viewModel = viewModel UITabBar.appearance().isTranslucent = false - UITabBar.appearance().barTintColor = UIColor(Theme.Colors.textInputUnfocusedBackground) - UITabBar.appearance().backgroundColor = UIColor(Theme.Colors.textInputUnfocusedBackground) + UITabBar.appearance().barTintColor = UIColor(Theme.Colors.tabbarColor) + UITabBar.appearance().backgroundColor = UIColor(Theme.Colors.tabbarColor) UITabBar.appearance().unselectedItemTintColor = UIColor(Theme.Colors.textSecondaryLight) UITabBarItem.appearance().setTitleTextAttributes( diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift index 78da9062a..2373b01b8 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift @@ -107,6 +107,7 @@ public struct DeleteAccountView: View { try await viewModel.deleteAccount(password: viewModel.password) } }, color: Theme.Colors.accentColor, + textColor: Theme.Colors.primaryButtonTextColor, isActive: viewModel.password.count >= 2) .padding(.top, 18) .accessibilityIdentifier("delete_account_button") diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index 1e76e1f22..0e0755e40 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -149,6 +149,7 @@ public struct ProfileView: View { Text(String(viewModel.userModel?.yearOfBirth ?? 0)) .accessibilityIdentifier("yob_value_text") } + .font(Theme.Fonts.bodyLarge) } if let bio = viewModel.userModel?.shortBiography, bio != "" { HStack(alignment: .top) { @@ -193,6 +194,7 @@ public struct ProfileView: View { }, label: { HStack { Text(ProfileLocalization.settingsVideo) + .font(Theme.Fonts.bodyLarge) Spacer() Image(systemName: "chevron.right") } diff --git a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift index 54338d755..f6f556a1d 100644 --- a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift +++ b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift @@ -114,7 +114,7 @@ struct ProfileSupportInfoView: View { HStack { Text(viewModel.title) .multilineTextAlignment(.leading) - .font(Theme.Fonts.bodyMedium) + .font(Theme.Fonts.labelLarge) .foregroundColor(Theme.Colors.textPrimary) Spacer() Image(systemName: "chevron.right") @@ -145,7 +145,7 @@ struct ProfileSupportInfoView: View { HStack { Text(linkViewModel.title) .foregroundColor(Theme.Colors.textPrimary) - .font(Theme.Fonts.bodyMedium) + .font(Theme.Fonts.labelLarge) Spacer() Image(systemName: "chevron.right") } @@ -173,6 +173,7 @@ struct ProfileSupportInfoView: View { .frame(width: 24, height: 24) } Text("\(ProfileLocalization.Settings.version) \(viewModel.currentVersion)") + .font(Theme.Fonts.labelLarge) } switch viewModel.versionState { case .actual: @@ -201,7 +202,6 @@ struct ProfileSupportInfoView: View { .frame(width: 24, height: 24) .foregroundStyle(Theme.Colors.accentColor) } - } }) .disabled(viewModel.versionState == .actual) diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index 7dc184467..ed423d84f 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -152,11 +152,11 @@ public struct SettingsCell: View { public var body: some View { VStack(alignment: .leading) { Text(title) - .font(Theme.Fonts.titleMedium) + .font(Theme.Fonts.bodyLarge) .accessibilityIdentifier("video_settings_text") if let description { Text(description) - .font(Theme.Fonts.labelMedium) + .font(Theme.Fonts.bodySmall) .foregroundColor(Theme.Colors.textSecondary) .accessibilityIdentifier("video_settings_sub_text") } diff --git a/Theme/Theme.xcodeproj/project.pbxproj b/Theme/Theme.xcodeproj/project.pbxproj index e67d7eb3d..66cb9579d 100644 --- a/Theme/Theme.xcodeproj/project.pbxproj +++ b/Theme/Theme.xcodeproj/project.pbxproj @@ -502,7 +502,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = 9G78FK843Y; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -595,7 +595,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = 9G78FK843Y; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -693,7 +693,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = 9G78FK843Y; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -786,7 +786,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = 9G78FK843Y; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -884,7 +884,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = 9G78FK843Y; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -977,7 +977,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = 9G78FK843Y; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1133,7 +1133,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = 9G78FK843Y; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1168,7 +1168,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = 9G78FK843Y; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; diff --git a/Theme/Theme/Assets.xcassets/Colors/PrimaryButtonTextColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/PrimaryButtonTextColor.colorset/Contents.json new file mode 100644 index 000000000..22c4bb0a8 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/PrimaryButtonTextColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/Colors/TabbarColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/TabbarColor.colorset/Contents.json new file mode 100644 index 000000000..2d9b9cd70 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/TabbarColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.984", + "green" : "0.980", + "red" : "0.976" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "display-p3", + "components" : { + "alpha" : "1.000", + "blue" : "0.275", + "green" : "0.200", + "red" : "0.153" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/SwiftGen/ThemeAssets.swift b/Theme/Theme/SwiftGen/ThemeAssets.swift index 8d352f649..38de85b3b 100644 --- a/Theme/Theme/SwiftGen/ThemeAssets.swift +++ b/Theme/Theme/SwiftGen/ThemeAssets.swift @@ -45,6 +45,7 @@ public enum ThemeAssets { public static let pastDueTimelineColor = ColorAsset(name: "pastDueTimelineColor") public static let loginBackground = ColorAsset(name: "LoginBackground") public static let loginNavigationText = ColorAsset(name: "LoginNavigationText") + public static let primaryButtonTextColor = ColorAsset(name: "PrimaryButtonTextColor") public static let onProgress = ColorAsset(name: "OnProgress") public static let progressDone = ColorAsset(name: "ProgressDone") public static let progressSkip = ColorAsset(name: "ProgressSkip") @@ -59,6 +60,7 @@ public enum ThemeAssets { public static let splashBackground = ColorAsset(name: "SplashBackground") public static let styledButtonText = ColorAsset(name: "StyledButtonText") public static let success = ColorAsset(name: "Success") + public static let tabbarColor = ColorAsset(name: "TabbarColor") public static let textPrimary = ColorAsset(name: "TextPrimary") public static let textSecondary = ColorAsset(name: "TextSecondary") public static let textSecondaryLight = ColorAsset(name: "TextSecondaryLight") diff --git a/Theme/Theme/Theme.swift b/Theme/Theme/Theme.swift index 75110db9a..f48fa1ba5 100644 --- a/Theme/Theme/Theme.swift +++ b/Theme/Theme/Theme.swift @@ -56,6 +56,8 @@ public struct Theme { public private(set) static var secondaryButtonBorderColor = ThemeAssets.secondaryButtonBorderColor.swiftUIColor public private(set) static var secondaryButtonTextColor = ThemeAssets.secondaryButtonTextColor.swiftUIColor public private(set) static var success = ThemeAssets.success.swiftUIColor + public private(set) static var tabbarColor = ThemeAssets.tabbarColor.swiftUIColor + public private(set) static var primaryButtonTextColor = ThemeAssets.primaryButtonTextColor.swiftUIColor public static func update( accentColor: Color = ThemeAssets.accentColor.swiftUIColor, @@ -95,7 +97,9 @@ public struct Theme { navigationBarTintColor: Color = ThemeAssets.navigationBarTintColor.swiftUIColor, secondaryButtonBorderColor: Color = ThemeAssets.secondaryButtonBorderColor.swiftUIColor, secondaryButtonTextColor: Color = ThemeAssets.secondaryButtonTextColor.swiftUIColor, - success: Color = ThemeAssets.success.swiftUIColor + success: Color = ThemeAssets.success.swiftUIColor, + tabbarColor: Color = ThemeAssets.tabbarColor.swiftUIColor, + primaryButtonTextColor: Color = ThemeAssets.primaryButtonTextColor.swiftUIColor ) { self.accentColor = accentColor self.accentXColor = accentXColor @@ -135,6 +139,8 @@ public struct Theme { self.secondaryButtonBorderColor = secondaryButtonBorderColor self.secondaryButtonTextColor = secondaryButtonTextColor self.success = success + self.tabbarColor = tabbarColor + self.primaryButtonTextColor = primaryButtonTextColor } } From 8ea2cb8562d141865830efdbe8d989d97f3f3bb1 Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Mon, 4 Mar 2024 17:25:22 +0500 Subject: [PATCH 046/136] refactor: improvement --- .../View/Base/VideoDownloadQualityView.swift | 4 ++-- .../Presentation/Profile/ProfileView.swift | 7 ++++--- .../Subviews/ProfileSupportInfoView.swift | 6 +++--- .../Presentation/Settings/SettingsView.swift | 2 +- Theme/Theme.xcodeproj/project.pbxproj | 16 ++++++++-------- 5 files changed, 18 insertions(+), 17 deletions(-) diff --git a/Core/Core/View/Base/VideoDownloadQualityView.swift b/Core/Core/View/Base/VideoDownloadQualityView.swift index b4bb391c6..7585d4c57 100644 --- a/Core/Core/View/Base/VideoDownloadQualityView.swift +++ b/Core/Core/View/Base/VideoDownloadQualityView.swift @@ -108,11 +108,11 @@ public struct SettingsCell: View { public var body: some View { VStack(alignment: .leading) { Text(title) - .font(Theme.Fonts.labelLarge) + .font(Theme.Fonts.titleMedium) .accessibilityIdentifier("video_quality_title_text") if let description { Text(description) - .font(Theme.Fonts.labelSmall) + .font(Theme.Fonts.bodySmall) .foregroundColor(Theme.Colors.textSecondary) .accessibilityIdentifier("video_quality_des_text") } diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index 0e0755e40..c1e3499c0 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -144,12 +144,13 @@ public struct ProfileView: View { if viewModel.userModel?.yearOfBirth != 0 { HStack { Text(ProfileLocalization.Edit.Fields.yearOfBirth) - .foregroundColor(Theme.Colors.textPrimary) + .foregroundColor(Theme.Colors.textSecondary) .accessibilityIdentifier("yob_text") Text(String(viewModel.userModel?.yearOfBirth ?? 0)) + .foregroundColor(Theme.Colors.textPrimary) .accessibilityIdentifier("yob_value_text") } - .font(Theme.Fonts.bodyLarge) + .font(Theme.Fonts.titleMedium) } if let bio = viewModel.userModel?.shortBiography, bio != "" { HStack(alignment: .top) { @@ -194,7 +195,7 @@ public struct ProfileView: View { }, label: { HStack { Text(ProfileLocalization.settingsVideo) - .font(Theme.Fonts.bodyLarge) + .font(Theme.Fonts.titleMedium) Spacer() Image(systemName: "chevron.right") } diff --git a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift index f6f556a1d..134b38536 100644 --- a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift +++ b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift @@ -114,7 +114,7 @@ struct ProfileSupportInfoView: View { HStack { Text(viewModel.title) .multilineTextAlignment(.leading) - .font(Theme.Fonts.labelLarge) + .font(Theme.Fonts.titleMedium) .foregroundColor(Theme.Colors.textPrimary) Spacer() Image(systemName: "chevron.right") @@ -145,7 +145,7 @@ struct ProfileSupportInfoView: View { HStack { Text(linkViewModel.title) .foregroundColor(Theme.Colors.textPrimary) - .font(Theme.Fonts.labelLarge) + .font(Theme.Fonts.titleMedium) Spacer() Image(systemName: "chevron.right") } @@ -173,7 +173,7 @@ struct ProfileSupportInfoView: View { .frame(width: 24, height: 24) } Text("\(ProfileLocalization.Settings.version) \(viewModel.currentVersion)") - .font(Theme.Fonts.labelLarge) + .font(Theme.Fonts.titleMedium) } switch viewModel.versionState { case .actual: diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index ed423d84f..494ecca11 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -152,7 +152,7 @@ public struct SettingsCell: View { public var body: some View { VStack(alignment: .leading) { Text(title) - .font(Theme.Fonts.bodyLarge) + .font(Theme.Fonts.titleMedium) .accessibilityIdentifier("video_settings_text") if let description { Text(description) diff --git a/Theme/Theme.xcodeproj/project.pbxproj b/Theme/Theme.xcodeproj/project.pbxproj index 66cb9579d..e67d7eb3d 100644 --- a/Theme/Theme.xcodeproj/project.pbxproj +++ b/Theme/Theme.xcodeproj/project.pbxproj @@ -502,7 +502,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 9G78FK843Y; + DEVELOPMENT_TEAM = L8PG7LC3Y3; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -595,7 +595,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 9G78FK843Y; + DEVELOPMENT_TEAM = L8PG7LC3Y3; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -693,7 +693,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 9G78FK843Y; + DEVELOPMENT_TEAM = L8PG7LC3Y3; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -786,7 +786,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 9G78FK843Y; + DEVELOPMENT_TEAM = L8PG7LC3Y3; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -884,7 +884,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 9G78FK843Y; + DEVELOPMENT_TEAM = L8PG7LC3Y3; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -977,7 +977,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 9G78FK843Y; + DEVELOPMENT_TEAM = L8PG7LC3Y3; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1133,7 +1133,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 9G78FK843Y; + DEVELOPMENT_TEAM = L8PG7LC3Y3; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1168,7 +1168,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = 9G78FK843Y; + DEVELOPMENT_TEAM = L8PG7LC3Y3; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; From 0c181b7a8b88266fb564bea64e2c9094320af43f Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Mon, 4 Mar 2024 21:14:47 +0500 Subject: [PATCH 047/136] chore: set application tint color as per accent color --- Core/Core/Extensions/UIApplicationExtension.swift | 2 +- OpenEdX/AppDelegate.swift | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Core/Core/Extensions/UIApplicationExtension.swift b/Core/Core/Extensions/UIApplicationExtension.swift index 3f1eaaf3e..00b7651fe 100644 --- a/Core/Core/Extensions/UIApplicationExtension.swift +++ b/Core/Core/Extensions/UIApplicationExtension.swift @@ -55,7 +55,7 @@ extension UINavigationController { UISegmentedControl.appearance().setTitleTextAttributes( [ - .foregroundColor: Theme.Colors.white.uiColor(), + .foregroundColor: Theme.Colors.primaryButtonTextColor.uiColor(), .font: Theme.UIFonts.labelLarge() ], for: .normal) diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index 1f71d7209..59138b48c 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -48,6 +48,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { window = UIWindow(frame: UIScreen.main.bounds) window?.rootViewController = RouteController() window?.makeKeyAndVisible() + window?.tintColor = Theme.UIColors.accentColor NotificationCenter.default.addObserver( self, From f931ae82dbe894626a35bc5b1e027b4c8cbf26ff Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Mon, 4 Mar 2024 21:40:01 +0500 Subject: [PATCH 048/136] refactor: fix logo color and forgot password style --- Authorization/Authorization/Presentation/Login/SignInView.swift | 2 +- .../Authorization/Presentation/Startup/StartupView.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index c84a32862..9d9acb0d9 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -127,7 +127,7 @@ public struct SignInView: View { viewModel.trackForgotPasswordClicked() viewModel.router.showForgotPasswordScreen() } - .font(Theme.Fonts.bodyMedium) + .font(Theme.Fonts.titleMedium) .foregroundColor(Theme.Colors.accentXColor) .padding(.top, 0) .accessibilityIdentifier("forgot_password_button") diff --git a/Authorization/Authorization/Presentation/Startup/StartupView.swift b/Authorization/Authorization/Presentation/Startup/StartupView.swift index bf5313947..5d56c01cc 100644 --- a/Authorization/Authorization/Presentation/Startup/StartupView.swift +++ b/Authorization/Authorization/Presentation/Startup/StartupView.swift @@ -26,7 +26,7 @@ public struct StartupView: View { public var body: some View { ZStack(alignment: .top) { VStack(alignment: .leading) { - ThemeAssets.appLogo.swiftUIImage + ThemeAssets.appLogo.swiftUIImage.renderingMode(.template) .resizable() .aspectRatio(contentMode: .fit) .frame(maxWidth: 189, maxHeight: 89) From 6f83e5d96c8b7e4e1d16c054dd85bdecd158e81c Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Mon, 4 Mar 2024 17:42:03 +0100 Subject: [PATCH 049/136] chore: added auto-play for video and youtube players --- Course/Course/Presentation/Video/EncodedVideoPlayer.swift | 3 +++ .../Presentation/Video/YouTubeVideoPlayerViewModel.swift | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift index c40fcabfa..6249ccb7b 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift @@ -80,6 +80,9 @@ public struct EncodedVideoPlayer: View { .aspectRatio(16 / 9, contentMode: .fit) .frame(minWidth: isHorizontal ? reader.size.width * 0.6 : 380) .cornerRadius(12) + .onAppear { + viewModel.controller.player?.play() + } if isHorizontal { Spacer() } diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift index f30c65f98..e3486d600 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift @@ -39,7 +39,7 @@ public class YouTubeVideoPlayerViewModel: VideoPlayerViewModel { let videoID = url.replacingOccurrences(of: "https://www.youtube.com/watch?v=", with: "") let configuration = YouTubePlayer.Configuration(configure: { - $0.autoPlay = false + $0.autoPlay = true $0.playInline = true $0.showFullscreenButton = true $0.allowsPictureInPictureMediaPlayback = false From ac10c9d252c52d4962dcd759fbd2a3e477a233e3 Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Tue, 5 Mar 2024 09:10:27 +0500 Subject: [PATCH 050/136] fix: handouts colors fix --- Core/Core/Configuration/CSSInjector.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Core/Configuration/CSSInjector.swift b/Core/Core/Configuration/CSSInjector.swift index 59beef4cd..689b30923 100644 --- a/Core/Core/Configuration/CSSInjector.swift +++ b/Core/Core/Configuration/CSSInjector.swift @@ -115,7 +115,7 @@ public class CSSInjector { " + } + return nil + } + + func titleHTML(fontFamily: String, fontSize: CGFloat, title: String) -> String { + """ +
+

+ \(title) +

+
+ """ + } + + func dividerHTML() -> String { + """ +
+
+ """ + } + + func announcemetsHtml() -> String? { + guard let announcements = announcements else {return nil} + var html: String = "" + let font = Theme.UIFonts.labelSmall() + let fontFamily = font.familyName + let fontSize = font.pointSize + + if let fontsCSS = fontsCSS(for: fontFamily) { + html.append(fontsCSS) + } + + for (index, ann) in announcements.enumerated() { + let titleHTML = titleHTML(fontFamily: fontFamily, fontSize: fontSize, title: ann.date) + html.append("
\(titleHTML)\n\(ann.content)
") + + if index != announcements.count - 1 { + html.append(dividerHTML()) + } + } + let formattedAnnouncements = cssInjector.injectCSS( + colorScheme: colorScheme, + html: html, + type: .discovery, + fontSize: 100, + screenWidth: .infinity + ) + + return """ + + \(formattedAnnouncements) + + """ + } } #if DEBUG From c769a681f86e729846a746980f46ee64ea958206 Mon Sep 17 00:00:00 2001 From: forgotvas Date: Tue, 19 Mar 2024 14:26:15 +0300 Subject: [PATCH 073/136] chore: fix divider --- .../Presentation/Handouts/HandoutsUpdatesDetailView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift b/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift index ea8babd71..2ac803a91 100644 --- a/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift @@ -155,7 +155,7 @@ public struct HandoutsUpdatesDetailView: View { margin-bottom: 3px !important; background-color: \(UIColor.opaqueSeparator.cgColor.hexString ?? "") !important; width: 100%; - height: 0.3px; + height: 0.5px; "> """ From 4930522833d5ff55dd083a9eb11b80aecf68a4b6 Mon Sep 17 00:00:00 2001 From: forgotvas Date: Tue, 19 Mar 2024 14:29:41 +0300 Subject: [PATCH 074/136] chore: refactor --- .../Handouts/HandoutsUpdatesDetailView.swift | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift b/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift index 2ac803a91..80b8ce43f 100644 --- a/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift @@ -189,9 +189,14 @@ public struct HandoutsUpdatesDetailView: View { ) return """ - - \(formattedAnnouncements) - + + + + + + \(formattedAnnouncements) + + """ } } From 988f85024cd426377c7660684fbab5285e4e7aa6 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta <127732735+volodymyr-chekyrta@users.noreply.github.com> Date: Tue, 19 Mar 2024 13:30:24 +0200 Subject: [PATCH 075/136] Fix/ci (#354) * fix: CI issue --- .github/workflows/unit_tests.yml | 2 +- Gemfile.lock | 108 +++++++++++++++---------------- ci_scripts/ci_prepare_env.sh | 4 +- fastlane/Fastfile | 9 ++- 4 files changed, 62 insertions(+), 61 deletions(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 16dee327e..fa683726e 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -11,7 +11,7 @@ on: jobs: tests: name: Tests - runs-on: macos-13 + runs-on: macos-14 concurrency: # When running on develop, use the sha to allow all runs of this workflow to run concurrently. diff --git a/Gemfile.lock b/Gemfile.lock index 8d7c4d571..b09a47585 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,42 +1,44 @@ GEM remote: https://rubygems.org/ specs: - CFPropertyList (3.0.6) + CFPropertyList (3.0.7) + base64 + nkf rexml - addressable (2.8.4) + addressable (2.8.6) public_suffix (>= 2.0.2, < 6.0) - artifactory (3.0.15) + artifactory (3.0.17) atomos (0.1.3) - aws-eventstream (1.2.0) - aws-partitions (1.782.0) - aws-sdk-core (3.176.1) - aws-eventstream (~> 1, >= 1.0.2) + aws-eventstream (1.3.0) + aws-partitions (1.899.0) + aws-sdk-core (3.191.4) + aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.5) + aws-sigv4 (~> 1.8) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.68.0) - aws-sdk-core (~> 3, >= 3.176.0) + aws-sdk-kms (1.78.0) + aws-sdk-core (~> 3, >= 3.191.0) aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.127.0) - aws-sdk-core (~> 3, >= 3.176.0) + aws-sdk-s3 (1.146.0) + aws-sdk-core (~> 3, >= 3.191.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.6) - aws-sigv4 (1.6.0) + aws-sigv4 (~> 1.8) + aws-sigv4 (1.8.0) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) + base64 (0.2.0) claide (1.1.0) colored (1.2) colored2 (3.1.2) commander (4.6.0) highline (~> 2.0.0) declarative (0.0.20) - digest-crc (0.6.4) + digest-crc (0.6.5) rake (>= 12.0.0, < 14.0.0) - domain_name (0.5.20190701) - unf (>= 0.0.5, < 1.0.0) + domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.100.0) + excon (0.110.0) faraday (1.10.3) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) @@ -65,8 +67,8 @@ GEM faraday-retry (1.0.3) faraday_middleware (1.2.0) faraday (~> 1.0) - fastimage (2.2.7) - fastlane (2.214.0) + fastimage (2.3.0) + fastlane (2.219.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -85,20 +87,22 @@ GEM gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.0.0) google-cloud-storage (~> 1.31) highline (~> 2.0) + http-cookie (~> 1.0.5) json (< 3.0.0) jwt (>= 2.1.0, < 3) mini_magick (>= 4.9.4, < 5.0.0) multipart-post (>= 2.0.0, < 3.0.0) naturally (~> 2.2) - optparse (~> 0.1.1) + optparse (>= 0.1.1) plist (>= 3.1.0, < 4.0.0) rubyzip (>= 2.0.0, < 3.0.0) security (= 0.1.3) simctl (~> 1.6.3) terminal-notifier (>= 2.0.0, < 3.0.0) - terminal-table (>= 1.4.5, < 2.0.0) + terminal-table (~> 3) tty-screen (>= 0.6.3, < 1.0.0) tty-spinner (>= 0.8.0, < 1.0.0) word_wrap (~> 1.0.0) @@ -106,9 +110,9 @@ GEM xcpretty (~> 0.3.0) xcpretty-travis-formatter (>= 0.0.3) gh_inspector (1.1.3) - google-apis-androidpublisher_v3 (0.44.0) + google-apis-androidpublisher_v3 (0.54.0) google-apis-core (>= 0.11.0, < 2.a) - google-apis-core (0.11.0) + google-apis-core (0.11.3) addressable (~> 2.5, >= 2.5.1) googleauth (>= 0.16.2, < 2.a) httpclient (>= 2.8.1, < 3.a) @@ -116,31 +120,29 @@ GEM representable (~> 3.0) retriable (>= 2.0, < 4.a) rexml - webrick google-apis-iamcredentials_v1 (0.17.0) google-apis-core (>= 0.11.0, < 2.a) google-apis-playcustomapp_v1 (0.13.0) google-apis-core (>= 0.11.0, < 2.a) - google-apis-storage_v1 (0.19.0) - google-apis-core (>= 0.9.0, < 2.a) - google-cloud-core (1.6.0) - google-cloud-env (~> 1.0) + google-apis-storage_v1 (0.31.0) + google-apis-core (>= 0.11.0, < 2.a) + google-cloud-core (1.7.0) + google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) google-cloud-env (1.6.0) faraday (>= 0.17.3, < 3.0) - google-cloud-errors (1.3.1) - google-cloud-storage (1.44.0) + google-cloud-errors (1.4.0) + google-cloud-storage (1.47.0) addressable (~> 2.8) digest-crc (~> 0.4) google-apis-iamcredentials_v1 (~> 0.1) - google-apis-storage_v1 (~> 0.19.0) + google-apis-storage_v1 (~> 0.31.0) google-cloud-core (~> 1.6) googleauth (>= 0.16.2, < 2.a) mini_mime (~> 1.0) - googleauth (1.6.0) + googleauth (1.8.1) faraday (>= 0.17.3, < 3.a) jwt (>= 1.4, < 3.0) - memoist (~> 0.16) multi_json (~> 1.11) os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) @@ -149,31 +151,32 @@ GEM domain_name (~> 0.5) httpclient (2.8.3) jmespath (1.6.2) - json (2.6.3) - jwt (2.7.1) - memoist (0.16.2) + json (2.7.1) + jwt (2.8.1) + base64 mini_magick (4.12.0) - mini_mime (1.1.2) + mini_mime (1.1.5) multi_json (1.15.0) - multipart-post (2.3.0) + multipart-post (2.4.0) nanaimo (0.3.0) naturally (2.2.1) - optparse (0.1.1) + nkf (0.2.0) + optparse (0.4.0) os (1.1.4) - plist (3.7.0) - public_suffix (5.0.1) - rake (13.0.6) + plist (3.7.1) + public_suffix (5.0.4) + rake (13.1.0) representable (3.2.0) declarative (< 0.1.0) trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) retriable (3.1.2) - rexml (3.2.5) + rexml (3.2.6) rouge (2.0.7) ruby2_keywords (0.0.5) rubyzip (2.3.2) security (0.1.3) - signet (0.17.0) + signet (0.19.0) addressable (~> 2.8) faraday (>= 0.17.5, < 3.a) jwt (>= 1.5, < 3.0) @@ -182,24 +185,20 @@ GEM CFPropertyList naturally terminal-notifier (2.0.0) - terminal-table (1.8.0) - unicode-display_width (~> 1.1, >= 1.1.1) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) trailblazer-option (0.1.2) tty-cursor (0.7.1) - tty-screen (0.8.1) + tty-screen (0.8.2) tty-spinner (0.9.3) tty-cursor (~> 0.7) uber (0.1.0) - unf (0.1.4) - unf_ext - unf_ext (0.0.8.2) - unicode-display_width (1.8.0) - webrick (1.8.1) + unicode-display_width (2.5.0) word_wrap (1.0.0) xcode-install (2.8.1) claide (>= 0.9.1) fastlane (>= 2.1.0, < 3.0.0) - xcodeproj (1.22.0) + xcodeproj (1.24.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) @@ -213,6 +212,7 @@ GEM PLATFORMS arm64-darwin-22 + arm64-darwin-23 DEPENDENCIES fastlane diff --git a/ci_scripts/ci_prepare_env.sh b/ci_scripts/ci_prepare_env.sh index 030de4198..4e071c960 100644 --- a/ci_scripts/ci_prepare_env.sh +++ b/ci_scripts/ci_prepare_env.sh @@ -35,12 +35,10 @@ install_xcode_cloud_brew_dependencies () { } setup_github_actions_environment() { - # brew update && brew install xcodegen git-lfs imagemagick - brew update && brew install xcodegen git-lfs + brew update && brew install xcodegen xcodesorg/made/xcodes git-lfs bundle config path vendor/bundle bundle install --jobs 4 --retry 3 - bundle update fastlane pod install } diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 35445da9e..d164d9825 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -11,10 +11,13 @@ # # Uncomment the line if you want fastlane to automatically update itself -# update_fastlane +update_fastlane before_all do - xcversion(version: "~> 15.0.0") + xcodes( + version: '15.2', + select_for_current_build_only: true, + ) ENV["FASTLANE_XCODEBUILD_SETTINGS_TIMEOUT"] = "180" ENV["FASTLANE_XCODE_LIST_TIMEOUT"] = "180" @@ -34,7 +37,7 @@ end lane :unit_tests do run_tests( workspace: "OpenEdX.xcworkspace", - device: "iPhone 14", + device: "iPhone 15", scheme: "OpenEdXDev" ) end From ae45456aac12b7d3f373f128889e48aafcac6dab Mon Sep 17 00:00:00 2001 From: Eugene Yatsenko Date: Tue, 19 Mar 2024 16:11:14 +0100 Subject: [PATCH 076/136] fix: disable create and add comments to discussions --- Core/Core/Extensions/DateExtension.swift | 11 ++++ .../Downloads/DownloadsView.swift | 12 ++-- .../CourseStructureNestedListView.swift | 28 +++++---- .../Discussion.xcodeproj/project.pbxproj | 8 +++ .../Data/Model/Data_DiscussionInfo.swift | 35 +++++++++++ .../Data/Network/DiscussionRepository.swift | 16 ++++- .../Domain/DiscussionInteractor.swift | 5 ++ .../Domain/Model/DiscussionInfo.swift | 31 ++++++++++ .../Comments/Responses/ResponsesView.swift | 25 +++++--- .../Responses/ResponsesViewModel.swift | 5 +- .../Comments/Thread/ThreadView.swift | 9 +-- .../Comments/Thread/ThreadViewModel.swift | 5 +- .../Presentation/DiscussionRouter.swift | 28 ++++++++- .../DiscussionSearchTopicsViewModel.swift | 3 +- .../DiscussionTopicsViewModel.swift | 40 +++++++++---- .../Presentation/Posts/PostsView.swift | 60 +++++++++++-------- .../Presentation/Posts/PostsViewModel.swift | 3 + .../DiscussionMock.generated.swift | 18 ++++++ .../DeepLinkManager/DeepLinkManager.swift | 32 +++++++--- .../DeepLinkRouter/DeepLinkRouter.swift | 30 +++++++--- OpenEdX/Router.swift | 11 +++- 21 files changed, 319 insertions(+), 96 deletions(-) create mode 100644 Discussion/Discussion/Data/Model/Data_DiscussionInfo.swift create mode 100644 Discussion/Discussion/Domain/Model/DiscussionInfo.swift diff --git a/Core/Core/Extensions/DateExtension.swift b/Core/Core/Extensions/DateExtension.swift index 2915fdc18..ca1a7d73b 100644 --- a/Core/Core/Extensions/DateExtension.swift +++ b/Core/Core/Extensions/DateExtension.swift @@ -7,6 +7,7 @@ import Foundation + public extension Date { init(iso8601: String) { let formats = ["yyyy-MM-dd'T'HH:mm:ssZ", "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ"] @@ -194,3 +195,13 @@ public extension Date { return selfYear == runningYear } } + +public extension Date { + func isEarlierThanOrEqualTo(date: Date) -> Bool { + timeIntervalSince1970 <= date.timeIntervalSince1970 + } + + func isLaterThanOrEqualTo(date: Date) -> Bool { + timeIntervalSince1970 >= date.timeIntervalSince1970 + } +} diff --git a/Course/Course/Presentation/Downloads/DownloadsView.swift b/Course/Course/Presentation/Downloads/DownloadsView.swift index 5c50634ea..3c648ead4 100644 --- a/Course/Course/Presentation/Downloads/DownloadsView.swift +++ b/Course/Course/Presentation/Downloads/DownloadsView.swift @@ -38,10 +38,14 @@ public struct DownloadsView: View { // MARK: - Body public var body: some View { - content - .sheetNavigation(isSheet: isSheet) { - dismiss() - } + ZStack { + Theme.Colors.background + .ignoresSafeArea() + content + .sheetNavigation(isSheet: isSheet) { + dismiss() + } + } } private var content: some View { diff --git a/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift b/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift index e057b3b7c..fed1ba259 100644 --- a/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift +++ b/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift @@ -80,22 +80,24 @@ struct CourseStructureNestedListView: View { Button { onLabelClick(sequential: sequential, chapter: chapter) } label: { - Group { - if sequential.completion == 1 { - CoreAssets.finished.swiftUIImage - .renderingMode(.template) - .foregroundColor(Theme.Colors.accentXColor) - } else { - sequential.type.image + HStack(spacing: 0) { + Group { + if sequential.completion == 1 { + CoreAssets.finished.swiftUIImage + .renderingMode(.template) + .foregroundColor(Theme.Colors.accentXColor) + } else { + sequential.type.image + } + Text(sequential.displayName) + .font(Theme.Fonts.titleMedium) + .multilineTextAlignment(.leading) + .lineLimit(1) } - Text(sequential.displayName) - .font(Theme.Fonts.titleMedium) - .multilineTextAlignment(.leading) - .lineLimit(1) + .foregroundColor(Theme.Colors.textPrimary) + Spacer() } - .foregroundColor(Theme.Colors.textPrimary) } - Spacer() downloadButton( sequential: sequential, chapter: chapter diff --git a/Discussion/Discussion.xcodeproj/project.pbxproj b/Discussion/Discussion.xcodeproj/project.pbxproj index 26c1a5518..2f63c3d88 100644 --- a/Discussion/Discussion.xcodeproj/project.pbxproj +++ b/Discussion/Discussion.xcodeproj/project.pbxproj @@ -58,6 +58,8 @@ 0766DFCA299AA3D400EBEF6A /* Data_CreatedComment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0766DFC9299AA3D400EBEF6A /* Data_CreatedComment.swift */; }; 7527943BE0D66C33B167A41A /* Pods_App_Discussion.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 66A7A0375EDDEB8948165EAD /* Pods_App_Discussion.framework */; }; 9FC0EF907C0334E383C300C4 /* Pods_App_Discussion_DiscussionTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C40A586C6164140DC2079231 /* Pods_App_Discussion_DiscussionTests.framework */; }; + BA3C45672BA9E13000672C96 /* Data_DiscussionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3C45662BA9E13000672C96 /* Data_DiscussionInfo.swift */; }; + BA3C45692BA9E18D00672C96 /* DiscussionInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA3C45682BA9E18D00672C96 /* DiscussionInfo.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -135,6 +137,8 @@ ACA8EF2DEDDB7695162C381A /* Pods-App-Discussion.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Discussion.debug.xcconfig"; path = "Target Support Files/Pods-App-Discussion/Pods-App-Discussion.debug.xcconfig"; sourceTree = ""; }; ACDA8EE733B97BCD8689A0B4 /* Pods-App-Discussion.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Discussion.debugstage.xcconfig"; path = "Target Support Files/Pods-App-Discussion/Pods-App-Discussion.debugstage.xcconfig"; sourceTree = ""; }; B472516A5A79E87C40766D93 /* Pods-App-Discussion-DiscussionTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Discussion-DiscussionTests.debug.xcconfig"; path = "Target Support Files/Pods-App-Discussion-DiscussionTests/Pods-App-Discussion-DiscussionTests.debug.xcconfig"; sourceTree = ""; }; + BA3C45662BA9E13000672C96 /* Data_DiscussionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_DiscussionInfo.swift; sourceTree = ""; }; + BA3C45682BA9E18D00672C96 /* DiscussionInfo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscussionInfo.swift; sourceTree = ""; }; BBF5EC7249109D6718A229CF /* Pods-App-Discussion.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Discussion.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Discussion/Pods-App-Discussion.debugdev.xcconfig"; sourceTree = ""; }; C214357310D8AC9185B38ABA /* Pods-App-Discussion-DiscussionTests.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Discussion-DiscussionTests.releasestage.xcconfig"; path = "Target Support Files/Pods-App-Discussion-DiscussionTests/Pods-App-Discussion-DiscussionTests.releasestage.xcconfig"; sourceTree = ""; }; C40A586C6164140DC2079231 /* Pods_App_Discussion_DiscussionTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_Discussion_DiscussionTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -256,6 +260,7 @@ 02F3BFE82926A57C0051930C /* Data_ThreadResponse.swift */, 02F3BFEA2926A5B50051930C /* Data_CommentsResponse.swift */, 0766DFC9299AA3D400EBEF6A /* Data_CreatedComment.swift */, + BA3C45662BA9E13000672C96 /* Data_DiscussionInfo.swift */, ); path = Model; sourceTree = ""; @@ -318,6 +323,7 @@ 0766DFC1299AA2A500EBEF6A /* DiscussionTopic.swift */, 0766DFC3299AA2C200EBEF6A /* Post.swift */, 0766DFC5299AA30500EBEF6A /* ThreadType.swift */, + BA3C45682BA9E18D00672C96 /* DiscussionInfo.swift */, ); path = Model; sourceTree = ""; @@ -689,6 +695,8 @@ 02F28A6028FF23F300AFDE1B /* ThreadViewModel.swift in Sources */, 0766DFBE299AA18A00EBEF6A /* DiscussionPost.swift in Sources */, 02F3BFED2926A6270051930C /* UserComment.swift in Sources */, + BA3C45672BA9E13000672C96 /* Data_DiscussionInfo.swift in Sources */, + BA3C45692BA9E18D00672C96 /* DiscussionInfo.swift in Sources */, 0282DA5F28F893CA003C3F07 /* PostsView.swift in Sources */, 0766DFC0299AA1CD00EBEF6A /* DiscussionNewThread.swift in Sources */, ); diff --git a/Discussion/Discussion/Data/Model/Data_DiscussionInfo.swift b/Discussion/Discussion/Data/Model/Data_DiscussionInfo.swift new file mode 100644 index 000000000..a0264897b --- /dev/null +++ b/Discussion/Discussion/Data/Model/Data_DiscussionInfo.swift @@ -0,0 +1,35 @@ +// +// Data_DiscussionInfo.swift +// Discussion +// +// Created by Eugene Yatsenko on 19.03.2024. +// + +import Foundation +import Core + +public struct DiscussionBlackout { + var start: String + var end: String +} + +public extension DataLayer { + struct DiscussionInfo: Codable { + var discussionID: String? + var blackouts: [DiscussionBlackout]? + } + + struct DiscussionBlackout: Codable { + var start: String + var end: String + } +} + +public extension DataLayer.DiscussionInfo { + var domain: DiscussionInfo { + .init( + discussionID: discussionID, + blackouts: blackouts?.compactMap { .init(start: $0.start, end: $0.end) } + ) + } +} diff --git a/Discussion/Discussion/Data/Network/DiscussionRepository.swift b/Discussion/Discussion/Data/Network/DiscussionRepository.swift index a939611e8..cc56f88c7 100644 --- a/Discussion/Discussion/Data/Network/DiscussionRepository.swift +++ b/Discussion/Discussion/Data/Network/DiscussionRepository.swift @@ -10,6 +10,7 @@ import Core import Combine public protocol DiscussionRepositoryProtocol { + func getCourseDiscussionInfo(courseID: String) async throws -> DiscussionInfo func getThreads(courseID: String, type: ThreadType, sort: SortType, @@ -49,7 +50,14 @@ public class DiscussionRepository: DiscussionRepositoryProtocol { self.config = config self.router = router } - + + public func getCourseDiscussionInfo(courseID: String) async throws -> DiscussionInfo { + let discussionInfo = try await api.requestData(DiscussionEndpoint + .getCourseDiscussionInfo(courseID: courseID)) + .mapResponse(DataLayer.DiscussionInfo.self) + return discussionInfo.domain + } + public func getThreads(courseID: String, type: ThreadType, sort: SortType, @@ -205,7 +213,11 @@ public class DiscussionRepository: DiscussionRepositoryProtocol { #if DEBUG // swiftlint:disable all public class DiscussionRepositoryMock: DiscussionRepositoryProtocol { - + + public func getCourseDiscussionInfo(courseID: String) async throws -> DiscussionInfo { + DiscussionInfo(discussionID: nil, blackouts: []) + } + public func getThread(threadID: String) async throws -> UserThread { UserThread( id: "", diff --git a/Discussion/Discussion/Domain/DiscussionInteractor.swift b/Discussion/Discussion/Domain/DiscussionInteractor.swift index 4fc92f804..561a15687 100644 --- a/Discussion/Discussion/Domain/DiscussionInteractor.swift +++ b/Discussion/Discussion/Domain/DiscussionInteractor.swift @@ -10,6 +10,7 @@ import Core //sourcery: AutoMockable public protocol DiscussionInteractorProtocol { + func getCourseDiscussionInfo(courseID: String) async throws -> DiscussionInfo func getThreadsList(courseID: String, type: ThreadType, sort: SortType, @@ -41,6 +42,10 @@ public class DiscussionInteractor: DiscussionInteractorProtocol { self.repository = repository } + public func getCourseDiscussionInfo(courseID: String) async throws -> DiscussionInfo { + try await repository.getCourseDiscussionInfo(courseID: courseID) + } + public func getThreadsList(courseID: String, type: ThreadType, sort: SortType, diff --git a/Discussion/Discussion/Domain/Model/DiscussionInfo.swift b/Discussion/Discussion/Domain/Model/DiscussionInfo.swift new file mode 100644 index 000000000..f471676b6 --- /dev/null +++ b/Discussion/Discussion/Domain/Model/DiscussionInfo.swift @@ -0,0 +1,31 @@ +// +// DiscussionInfo.swift +// Discussion +// +// Created by Eugene Yatsenko on 19.03.2024. +// + +import Foundation + +public struct DiscussionInfo { + public var discussionID: String? + public var blackouts: [DiscussionBlackout]? + + public func isBlackedOut() -> Bool { + guard let blackouts = blackouts else { + return false + } + var isBlackedOut = false + for blackout in blackouts { + let start = Date(iso8601: blackout.start) + let end = Date(iso8601: blackout.end) + + if Date().isEarlierThanOrEqualTo(date: end) && + Date().isLaterThanOrEqualTo(date: start) { + isBlackedOut = true + } + } + + return isBlackedOut + } +} diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index 34069c0b4..8237f9a91 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -19,11 +19,14 @@ public struct ResponsesView: View { @ObservedObject private var viewModel: ResponsesViewModel @State private var isShowProgress: Bool = true - - public init(commentID: String, - viewModel: ResponsesViewModel, - router: DiscussionRouter, - parentComment: Post) { + + public init( + commentID: String, + viewModel: ResponsesViewModel, + router: DiscussionRouter, + parentComment: Post, + isBlackedOut: Bool + ) { self.commentID = commentID self.parentComment = parentComment self.title = DiscussionLocalization.Response.commentsResponses @@ -33,6 +36,7 @@ public struct ResponsesView: View { await viewModel.getComments(commentID: commentID, parentComment: parentComment, page: 1) } viewModel.addCommentsIsVisible = false + self.viewModel.isBlackedOut = isBlackedOut } public var body: some View { @@ -53,7 +57,8 @@ public struct ResponsesView: View { if let comments = viewModel.postComments { ParentCommentView( comments: comments, - isThread: false, onAvatarTap: { username in + isThread: false, + onAvatarTap: { username in viewModel.router.showUserDetails(username: username) }, onLikeTap: { @@ -151,7 +156,7 @@ public struct ResponsesView: View { } }.frameLimit() - if !parentComment.closed { + if !(parentComment.closed || viewModel.isBlackedOut) { FlexibleKeyboardInputView( hint: DiscussionLocalization.Response.addComment, sendText: { commentText in @@ -246,7 +251,8 @@ struct ResponsesView_Previews: PreviewProvider { commentID: "", viewModel: viewModel, router: router, - parentComment: post + parentComment: post, + isBlackedOut: false ) .loadFonts() .preferredColorScheme(.light) @@ -256,7 +262,8 @@ struct ResponsesView_Previews: PreviewProvider { commentID: "", viewModel: viewModel, router: router, - parentComment: post + parentComment: post, + isBlackedOut: false ) .loadFonts() .preferredColorScheme(.dark) diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift index cc7ec0820..aa74efb61 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift @@ -13,9 +13,10 @@ import Combine public class ResponsesViewModel: BaseResponsesViewModel, ObservableObject { @Published var scrollTrigger: Bool = false - private let threadStateSubject: CurrentValueSubject - + + public var isBlackedOut: Bool = false + public init( interactor: DiscussionInteractorProtocol, router: DiscussionRouter, diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index b8bf0167b..fc0ed17ed 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -81,7 +81,7 @@ public struct ThreadView: View { } } ) - + HStack { Text("\(viewModel.itemsCount)") Text(DiscussionLocalization.responsesCount(viewModel.itemsCount)) @@ -92,7 +92,7 @@ public struct ThreadView: View { .padding(.leading, 24) .font(Theme.Fonts.titleMedium) .foregroundColor(Theme.Colors.textPrimary) - + ForEach(Array(comments.comments.enumerated()), id: \.offset) { index, comment in CommentCell( comment: comment, @@ -123,7 +123,8 @@ public struct ThreadView: View { viewModel.router.showComments( commentID: comment.commentID, parentComment: comment, - threadStateSubject: viewModel.threadStateSubject, + threadStateSubject: viewModel.threadStateSubject, + isBlackedOut: viewModel.isBlackedOut, animated: true ) }, @@ -152,7 +153,7 @@ public struct ThreadView: View { viewModel.sendUpdateUnreadState() } } - if !thread.closed { + if !(thread.closed || viewModel.isBlackedOut) { FlexibleKeyboardInputView( hint: DiscussionLocalization.Thread.addResponse, sendText: { commentText in diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift index bc9be9fac..247a097d0 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift @@ -15,9 +15,10 @@ public class ThreadViewModel: BaseResponsesViewModel, ObservableObject { internal let threadStateSubject = CurrentValueSubject(nil) private var cancellable: AnyCancellable? - private let postStateSubject: CurrentValueSubject - + + public var isBlackedOut: Bool = false + public init( interactor: DiscussionInteractorProtocol, router: DiscussionRouter, diff --git a/Discussion/Discussion/Presentation/DiscussionRouter.swift b/Discussion/Discussion/Presentation/DiscussionRouter.swift index 4fbdc31ad..951e22351 100644 --- a/Discussion/Discussion/Presentation/DiscussionRouter.swift +++ b/Discussion/Discussion/Presentation/DiscussionRouter.swift @@ -14,9 +14,21 @@ public protocol DiscussionRouter: BaseRouter { func showUserDetails(username: String) - func showThreads(courseID: String, topics: Topics, title: String, type: ThreadType, animated: Bool) + func showThreads( + courseID: String, + topics: Topics, + title: String, + type: ThreadType, + isBlackedOut: Bool, + animated: Bool + ) - func showThread(thread: UserThread, postStateSubject: CurrentValueSubject, animated: Bool) + func showThread( + thread: UserThread, + postStateSubject: CurrentValueSubject, + isBlackedOut: Bool, + animated: Bool + ) func showDiscussionsSearch(courseID: String) @@ -24,6 +36,7 @@ public protocol DiscussionRouter: BaseRouter { commentID: String, parentComment: Post, threadStateSubject: CurrentValueSubject, + isBlackedOut: Bool, animated: Bool ) @@ -38,11 +51,19 @@ public class DiscussionRouterMock: BaseRouterMock, DiscussionRouter { public func showUserDetails(username: String) {} - public func showThreads(courseID: String, topics: Topics, title: String, type: ThreadType, animated: Bool) {} + public func showThreads( + courseID: String, + topics: Topics, + title: String, + type: ThreadType, + isBlackedOut: Bool, + animated: Bool + ) {} public func showThread( thread: UserThread, postStateSubject: CurrentValueSubject, + isBlackedOut: Bool, animated: Bool ) {} @@ -52,6 +73,7 @@ public class DiscussionRouterMock: BaseRouterMock, DiscussionRouter { commentID: String, parentComment: Post, threadStateSubject: CurrentValueSubject, + isBlackedOut: Bool, animated: Bool ) {} diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift index 928e4b028..5f3285c82 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift @@ -161,7 +161,8 @@ public class DiscussionSearchTopicsViewModel: ObservableObject { guard let self else { return } self.router.showThread( thread: thread, - postStateSubject: self.postStateSubject, + postStateSubject: self.postStateSubject, + isBlackedOut: false, animated: true ) })) diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift index 9aa950084..cd635e359 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift @@ -31,31 +31,38 @@ public class DiscussionTopicsViewModel: ObservableObject { let router: DiscussionRouter let analytics: DiscussionAnalytics let config: ConfigProtocol - - public init(title: String, - interactor: DiscussionInteractorProtocol, - router: DiscussionRouter, - analytics: DiscussionAnalytics, - config: ConfigProtocol) { + + private(set) var isBlackedOut: Bool = false + + public init( + title: String, + interactor: DiscussionInteractorProtocol, + router: DiscussionRouter, + analytics: DiscussionAnalytics, + config: ConfigProtocol + ) { self.title = title self.interactor = interactor self.router = router self.analytics = analytics self.config = config } - + func generateTopics(topics: Topics?) -> [DiscussionTopic] { var result = [ DiscussionTopic( name: DiscussionLocalization.Topics.allPosts, action: { - self.analytics.discussionAllPostsClicked(courseId: self.courseID, - courseName: self.title) + self.analytics.discussionAllPostsClicked( + courseId: self.courseID, + courseName: self.title + ) self.router.showThreads( courseID: self.courseID, topics: topics ?? Topics(coursewareTopics: [], nonCoursewareTopics: []), title: DiscussionLocalization.Topics.allPosts, type: .allPosts, + isBlackedOut: self.isBlackedOut, animated: true ) }, @@ -63,13 +70,16 @@ public class DiscussionTopicsViewModel: ObservableObject { ), DiscussionTopic( name: DiscussionLocalization.Topics.postImFollowing, action: { - self.analytics.discussionFollowingClicked(courseId: self.courseID, - courseName: self.title) + self.analytics.discussionFollowingClicked( + courseId: self.courseID, + courseName: self.title + ) self.router.showThreads( courseID: self.courseID, topics: topics ?? Topics(coursewareTopics: [], nonCoursewareTopics: []), title: DiscussionLocalization.Topics.postImFollowing, type: .followingPosts, + isBlackedOut: self.isBlackedOut, animated: true ) }, @@ -92,6 +102,7 @@ public class DiscussionTopicsViewModel: ObservableObject { topics: topics, title: t.name, type: .nonCourseTopics, + isBlackedOut: self.isBlackedOut, animated: true ) @@ -114,6 +125,7 @@ public class DiscussionTopicsViewModel: ObservableObject { topics: topics, title: t.name, type: .nonCourseTopics, + isBlackedOut: self.isBlackedOut, animated: true ) }, @@ -144,6 +156,7 @@ public class DiscussionTopicsViewModel: ObservableObject { topics: topics, title: child.name, type: .courseTopics(topicID: child.id), + isBlackedOut: self.isBlackedOut, animated: true ) }, @@ -154,12 +167,15 @@ public class DiscussionTopicsViewModel: ObservableObject { } return result } - + @MainActor public func getTopics(courseID: String, withProgress: Bool = true) async { self.courseID = courseID isShowProgress = withProgress do { + let discussionInfo = try await interactor.getCourseDiscussionInfo(courseID: courseID) + isBlackedOut = discussionInfo.isBlackedOut() + topics = try await interactor.getTopics(courseID: courseID) discussionTopics = generateTopics(topics: topics) isShowProgress = false diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index f599b111d..16820fbd8 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -20,7 +20,7 @@ public struct PostsView: View { private let currentBlockID: String private let courseID: String private var showTopMenu: Bool - + public init( courseID: String, currentBlockID: String, @@ -29,7 +29,8 @@ public struct PostsView: View { type: ThreadType, viewModel: PostsViewModel, router: DiscussionRouter, - showTopMenu: Bool = true + showTopMenu: Bool = true, + isBlackedOut: Bool = false ) { self.courseID = courseID self.title = title @@ -37,18 +38,25 @@ public struct PostsView: View { self.router = router self.showTopMenu = showTopMenu self.viewModel = viewModel + self.viewModel.isBlackedOut = isBlackedOut self.viewModel.courseID = courseID self.viewModel.topics = topics viewModel.type = type } - public init(courseID: String, router: DiscussionRouter, viewModel: PostsViewModel) { + public init( + courseID: String, + router: DiscussionRouter, + viewModel: PostsViewModel, + isBlackedOut: Bool = false + ) { self.courseID = courseID self.title = "" self.currentBlockID = "" self.router = router self.viewModel = viewModel self.showTopMenu = true + self.viewModel.isBlackedOut = isBlackedOut self.viewModel.courseID = courseID } @@ -119,29 +127,31 @@ public struct PostsView: View { .font(Theme.Fonts.titleLarge) .foregroundColor(Theme.Colors.textPrimary) Spacer() - Button(action: { - router.createNewThread( - courseID: courseID, - selectedTopic: currentBlockID, - onPostCreated: { - reloadPage(onSuccess: { - withAnimation { - scroll.scrollTo(1) - } + if !viewModel.isBlackedOut { + Button(action: { + router.createNewThread( + courseID: courseID, + selectedTopic: currentBlockID, + onPostCreated: { + reloadPage(onSuccess: { + withAnimation { + scroll.scrollTo(1) + } + }) }) - }) - }, label: { - VStack { - CoreAssets.addComment.swiftUIImage - .font(Theme.Fonts.labelLarge) - .padding(6) - } - .foregroundColor(Theme.Colors.white) - .background( - Circle() - .foregroundColor(Theme.Colors.accentButtonColor) - ) - }) + }, label: { + VStack { + CoreAssets.addComment.swiftUIImage + .font(Theme.Fonts.labelLarge) + .padding(6) + } + .foregroundColor(Theme.Colors.white) + .background( + Circle() + .foregroundColor(Theme.Colors.accentButtonColor) + ) + }) + } } .padding(.horizontal, 24) diff --git a/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift b/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift index af567009c..3f72e7d60 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift @@ -61,6 +61,8 @@ public class PostsViewModel: ObservableObject { @Published var filterButtons: [ActionSheet.Button] = [] public var courseID: String? + var isBlackedOut: Bool = false + var errorMessage: String? { didSet { withAnimation { @@ -161,6 +163,7 @@ public class PostsViewModel: ObservableObject { self.router.showThread( thread: actualThread, postStateSubject: self.postStateSubject, + isBlackedOut: self.isBlackedOut, animated: true ) })) diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index 2202fd248..2b7dda63b 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -1374,6 +1374,12 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock + open func getCourseDiscussionInfo(courseID: String) { + addInvocation(.m_getCourseDiscussionInfo__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_getCourseDiscussionInfo__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + } + open func getThreadsList(courseID: String, type: ThreadType, sort: SortType, filter: ThreadsFilter, page: Int) throws -> ThreadLists { addInvocation(.m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page(Parameter.value(`courseID`), Parameter.value(`type`), Parameter.value(`sort`), Parameter.value(`filter`), Parameter.value(`page`))) let perform = methodPerformValue(.m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page(Parameter.value(`courseID`), Parameter.value(`type`), Parameter.value(`sort`), Parameter.value(`filter`), Parameter.value(`page`))) as? (String, ThreadType, SortType, ThreadsFilter, Int) -> Void @@ -1627,6 +1633,7 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock fileprivate enum MethodType { + case m_getCourseDiscussionInfo__courseID_courseID(Parameter) case m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page(Parameter, Parameter, Parameter, Parameter, Parameter) case m_getTopics__courseID_courseID(Parameter) case m_getTopic__courseID_courseIDtopicID_topicID(Parameter, Parameter) @@ -1647,6 +1654,11 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { + case (.m_getCourseDiscussionInfo__courseID_courseID(let lhsCourseid), .m_getCourseDiscussionInfo__courseID_courseID(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + return Matcher.ComparisonResult(results) + case (.m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page(let lhsCourseid, let lhsType, let lhsSort, let lhsFilter, let lhsPage), .m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page(let rhsCourseid, let rhsType, let rhsSort, let rhsFilter, let rhsPage)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) @@ -1754,6 +1766,7 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock func intValue() -> Int { switch self { + case let .m_getCourseDiscussionInfo__courseID_courseID(p0): return p0.intValue case let .m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page(p0, p1, p2, p3, p4): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue case let .m_getTopics__courseID_courseID(p0): return p0.intValue case let .m_getTopic__courseID_courseIDtopicID_topicID(p0, p1): return p0.intValue + p1.intValue @@ -1775,6 +1788,7 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock } func assertionName() -> String { switch self { + case .m_getCourseDiscussionInfo__courseID_courseID: return ".getCourseDiscussionInfo(courseID:)" case .m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page: return ".getThreadsList(courseID:type:sort:filter:page:)" case .m_getTopics__courseID_courseID: return ".getTopics(courseID:)" case .m_getTopic__courseID_courseIDtopicID_topicID: return ".getTopic(courseID:topicID:)" @@ -2010,6 +2024,7 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock public struct Verify { fileprivate var method: MethodType + public static func getCourseDiscussionInfo(courseID: Parameter) -> Verify { return Verify(method: .m_getCourseDiscussionInfo__courseID_courseID(`courseID`))} public static func getThreadsList(courseID: Parameter, type: Parameter, sort: Parameter, filter: Parameter, page: Parameter) -> Verify { return Verify(method: .m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page(`courseID`, `type`, `sort`, `filter`, `page`))} public static func getTopics(courseID: Parameter) -> Verify { return Verify(method: .m_getTopics__courseID_courseID(`courseID`))} public static func getTopic(courseID: Parameter, topicID: Parameter) -> Verify { return Verify(method: .m_getTopic__courseID_courseIDtopicID_topicID(`courseID`, `topicID`))} @@ -2033,6 +2048,9 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock fileprivate var method: MethodType var performs: Any + public static func getCourseDiscussionInfo(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getCourseDiscussionInfo__courseID_courseID(`courseID`), performs: perform) + } public static func getThreadsList(courseID: Parameter, type: Parameter, sort: Parameter, filter: Parameter, page: Parameter, perform: @escaping (String, ThreadType, SortType, ThreadsFilter, Int) -> Void) -> Perform { return Perform(method: .m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page(`courseID`, `type`, `sort`, `filter`, `page`), performs: perform) } diff --git a/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift b/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift index 58382005d..33d3ba70f 100644 --- a/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift +++ b/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift @@ -263,7 +263,16 @@ public class DeepLinkManager { if self.isDiscussionThreads(type: type) { self.router.showProgress() Task { - await self.showCourseDiscussion(link: link, courseDetails: courseDetails) + guard let discussionInfo = try? await self.discussionInteractor.getCourseDiscussionInfo( + courseID: courseDetails.courseID + ) else { + return + } + await self.showCourseDiscussion( + link: link, + courseDetails: courseDetails, + isBlackedOut: discussionInfo.isBlackedOut() + ) self.router.dismissProgress() } return @@ -297,7 +306,8 @@ public class DeepLinkManager { @MainActor private func showCourseDiscussion( link: DeepLink, - courseDetails: CourseDetails + courseDetails: CourseDetails, + isBlackedOut: Bool ) async { switch link.type { case .discussionTopic: @@ -316,7 +326,8 @@ public class DeepLinkManager { router.showThreads( topicID: topicID, courseDetails: courseDetails, - topics: topics + topics: topics, + isBlackedOut: isBlackedOut ) case .discussionPost: @@ -329,7 +340,8 @@ public class DeepLinkManager { router.showThreads( topicID: topicID, courseDetails: courseDetails, - topics: topics + topics: topics, + isBlackedOut: isBlackedOut ) } @@ -337,7 +349,8 @@ public class DeepLinkManager { !threadID.isEmpty, let userThread = try? await discussionInteractor.getThread(threadID: threadID) { router.showThread( - userThread: userThread + userThread: userThread, + isBlackedOut: isBlackedOut ) } @@ -351,7 +364,8 @@ public class DeepLinkManager { router.showThreads( topicID: topicID, courseDetails: courseDetails, - topics: topics + topics: topics, + isBlackedOut: isBlackedOut ) } @@ -359,7 +373,8 @@ public class DeepLinkManager { !threadID.isEmpty, let userThread = try? await discussionInteractor.getThread(threadID: threadID) { router.showThread( - userThread: userThread + userThread: userThread, + isBlackedOut: isBlackedOut ) } @@ -371,7 +386,8 @@ public class DeepLinkManager { let parentComment = try? await self.discussionInteractor.getResponse(responseID: parentID) { router.showComment( comment: comment, - parentComment: parentComment.post + parentComment: parentComment.post, + isBlackedOut: isBlackedOut ) } default: diff --git a/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift b/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift index 48a37bd20..b0ce73135 100644 --- a/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift +++ b/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift @@ -36,14 +36,17 @@ public protocol DeepLinkRouter: BaseRouter { func showThreads( topicID: String, courseDetails: CourseDetails, - topics: Topics + topics: Topics, + isBlackedOut: Bool ) func showThread( - userThread: UserThread + userThread: UserThread, + isBlackedOut: Bool ) func showComment( comment: UserComment, - parentComment: Post + parentComment: Post, + isBlackedOut: Bool ) func showProgram( pathID: String @@ -195,7 +198,8 @@ extension Router: DeepLinkRouter { public func showThreads( topicID: String, courseDetails: CourseDetails, - topics: Topics + topics: Topics, + isBlackedOut: Bool ) { popToCourseContainerView() @@ -205,28 +209,33 @@ extension Router: DeepLinkRouter { topics: topics, title: title, type: .courseTopics(topicID: topicID), + isBlackedOut: isBlackedOut, animated: false ) } public func showThread( - userThread: UserThread + userThread: UserThread, + isBlackedOut: Bool ) { showThread( thread: userThread, postStateSubject: .init(.none), + isBlackedOut: isBlackedOut, animated: false ) } public func showComment( comment: UserComment, - parentComment: Post + parentComment: Post, + isBlackedOut: Bool ) { showComments( commentID: comment.commentID, parentComment: parentComment, threadStateSubject: .init(.none), + isBlackedOut: isBlackedOut, animated: false ) } @@ -337,14 +346,17 @@ public class DeepLinkRouterMock: BaseRouterMock, DeepLinkRouter { public func showThreads( topicID: String, courseDetails: CourseDetails, - topics: Topics + topics: Topics, + isBlackedOut: Bool ) {} public func showThread( - userThread: UserThread + userThread: UserThread, + isBlackedOut: Bool ) {} public func showComment( comment: UserComment, - parentComment: Post + parentComment: Post, + isBlackedOut: Bool ) {} public func showProgram( pathID: String diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 8e9667458..55e806c40 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -502,6 +502,7 @@ public class Router: AuthorizationRouter, topics: Topics, title: String, type: ThreadType, + isBlackedOut: Bool, animated: Bool ) { let router = Container.shared.resolve(DiscussionRouter.self)! @@ -513,7 +514,8 @@ public class Router: AuthorizationRouter, title: title, type: type, viewModel: viewModel, - router: router + router: router, + isBlackedOut: isBlackedOut ) let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: animated) @@ -522,9 +524,11 @@ public class Router: AuthorizationRouter, public func showThread( thread: UserThread, postStateSubject: CurrentValueSubject, + isBlackedOut: Bool, animated: Bool ) { let viewModel = Container.shared.resolve(ThreadViewModel.self, argument: postStateSubject)! + viewModel.isBlackedOut = isBlackedOut let view = ThreadView(thread: thread, viewModel: viewModel) let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: animated) @@ -534,15 +538,18 @@ public class Router: AuthorizationRouter, commentID: String, parentComment: Post, threadStateSubject: CurrentValueSubject, + isBlackedOut: Bool, animated: Bool ) { let router = Container.shared.resolve(DiscussionRouter.self)! let viewModel = Container.shared.resolve(ResponsesViewModel.self, argument: threadStateSubject)! + viewModel.isBlackedOut = isBlackedOut let view = ResponsesView( commentID: commentID, viewModel: viewModel, router: router, - parentComment: parentComment + parentComment: parentComment, + isBlackedOut: isBlackedOut ) let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: animated) From e5e3ce5af5cbccf6689636d02e3965ea9e8dc5b9 Mon Sep 17 00:00:00 2001 From: Eugene Yatsenko Date: Tue, 19 Mar 2024 16:35:08 +0100 Subject: [PATCH 077/136] fix: downloads view background color --- Core/Core/Extensions/ViewExtension.swift | 30 ++++++++++++++---------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/Core/Core/Extensions/ViewExtension.swift b/Core/Core/Extensions/ViewExtension.swift index 1700e774f..e295f996d 100644 --- a/Core/Core/Extensions/ViewExtension.swift +++ b/Core/Core/Extensions/ViewExtension.swift @@ -261,21 +261,25 @@ public extension View { func sheetNavigation(isSheet: Bool, onDismiss: (() -> Void)? = nil) -> some View { if isSheet { NavigationView { - self - .if(onDismiss != nil) { view in - view - .toolbar { - ToolbarItem(placement: .navigationBarTrailing) { - Button { - onDismiss?() - } label: { - Image(systemName: "xmark") - .foregroundColor(Theme.Colors.accentColor) + ZStack { + Theme.Colors.background + .ignoresSafeArea() + self + .if(onDismiss != nil) { view in + view + .toolbar { + ToolbarItem(placement: .navigationBarTrailing) { + Button { + onDismiss?() + } label: { + Image(systemName: "xmark") + .foregroundColor(Theme.Colors.accentColor) + } + .accessibilityIdentifier("close_button") } - .accessibilityIdentifier("close_button") } - } - } + } + } } } else { self From 3ae346fb7b4a903dc1a60c3ae591b02a09cc312c Mon Sep 17 00:00:00 2001 From: Vadim Kuznetsov Date: Tue, 19 Mar 2024 18:36:23 +0300 Subject: [PATCH 078/136] =?UTF-8?q?fix:=20Unable=20to=20open=20any=20item?= =?UTF-8?q?=20from=20"Bachelor=E2=80=99s=20Degrees"=20(#344)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [iOS] Unable to open any item from "Bachelor’s Degrees" section in Discover tab #343 --- Core/Core/View/Base/Webview/WebView.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Core/Core/View/Base/Webview/WebView.swift b/Core/Core/View/Base/Webview/WebView.swift index 54fa11796..df174b904 100644 --- a/Core/Core/View/Base/Webview/WebView.swift +++ b/Core/Core/View/Base/Webview/WebView.swift @@ -53,6 +53,7 @@ public struct WebView: UIViewRepresentable { public class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler { var parent: WebView + var url: URL? init(_ parent: WebView) { self.parent = parent @@ -252,16 +253,18 @@ public struct WebView: UIViewRepresentable { } webView.customUserAgent = userAgent + context.coordinator.url = nil return webView } public func updateUIView(_ webview: WKWebView, context: UIViewRepresentableContext) { if let url = URL(string: viewModel.url) { - if webview.url?.absoluteString != url.absoluteString { + if context.coordinator.url?.absoluteString != url.absoluteString { DispatchQueue.main.async { isLoading = true } + context.coordinator.url = url let request = URLRequest(url: url) webview.load(request) } From c6d7fde4d5e694b5130bce7d35a1b529a6203839 Mon Sep 17 00:00:00 2001 From: Eugene Yatsenko Date: Wed, 20 Mar 2024 12:06:52 +0100 Subject: [PATCH 079/136] chore: add banner if discussions disabled --- .../DiscussionTopicsView.swift | 18 ++++++++++++++++++ .../DiscussionTopicsViewModel.swift | 3 +-- Discussion/Discussion/SwiftGen/Strings.swift | 4 ++++ .../Discussion/en.lproj/Localizable.strings | 1 + .../Discussion/uk.lproj/Localizable.strings | 1 + 5 files changed, 25 insertions(+), 2 deletions(-) diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift index c5d84f55c..603aa06ac 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift @@ -26,6 +26,10 @@ public struct DiscussionTopicsView: View { ZStack(alignment: .center) { VStack(alignment: .center) { // MARK: - Search fake field + if viewModel.isBlackedOut { + bannerDiscussionsDisabled + } + HStack(spacing: 11) { Image(systemName: "magnifyingglass") .foregroundColor(Theme.Colors.textSecondary) @@ -162,6 +166,20 @@ public struct DiscussionTopicsView: View { .ignoresSafeArea() ) } + + private var bannerDiscussionsDisabled: some View { + HStack { + Spacer() + Text(DiscussionLocalization.Banner.discussionsIsDisabled) + .font(Theme.Fonts.titleSmall) + .foregroundStyle(.black) + .multilineTextAlignment(.center) + .padding(.vertical, 10) + Spacer() + } + .background(Theme.Colors.warning) + .padding(.bottom, 10) + } } #if DEBUG diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift index cd635e359..460a1ca3a 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift @@ -17,6 +17,7 @@ public class DiscussionTopicsViewModel: ObservableObject { @Published var showError: Bool = false @Published var discussionTopics: [DiscussionTopic]? @Published var courseID: String = "" + @Published private(set) var isBlackedOut: Bool = false let title: String var errorMessage: String? { @@ -32,8 +33,6 @@ public class DiscussionTopicsViewModel: ObservableObject { let analytics: DiscussionAnalytics let config: ConfigProtocol - private(set) var isBlackedOut: Bool = false - public init( title: String, interactor: DiscussionInteractorProtocol, diff --git a/Discussion/Discussion/SwiftGen/Strings.swift b/Discussion/Discussion/SwiftGen/Strings.swift index 1ad256ce1..01a7dd461 100644 --- a/Discussion/Discussion/SwiftGen/Strings.swift +++ b/Discussion/Discussion/SwiftGen/Strings.swift @@ -39,6 +39,10 @@ public enum DiscussionLocalization { public static func votesCount(_ p1: Int) -> String { return DiscussionLocalization.tr("Localizable", "votes_count", p1, fallback: "Plural format key: \"%#@votes@\"") } + public enum Banner { + /// Posting in discussions is disabled by the course team + public static let discussionsIsDisabled = DiscussionLocalization.tr("Localizable", "BANNER.DISCUSSIONS_IS_DISABLED", fallback: "Posting in discussions is disabled by the course team") + } public enum Comment { /// Follow public static let follow = DiscussionLocalization.tr("Localizable", "COMMENT.FOLLOW", fallback: "Follow") diff --git a/Discussion/Discussion/en.lproj/Localizable.strings b/Discussion/Discussion/en.lproj/Localizable.strings index 553b74123..c88b148a3 100644 --- a/Discussion/Discussion/en.lproj/Localizable.strings +++ b/Discussion/Discussion/en.lproj/Localizable.strings @@ -7,6 +7,7 @@ */ "TITLE" = "Discussions"; +"BANNER.DISCUSSIONS_IS_DISABLED" = "Posting in discussions is disabled by the course team"; "TOPICS.SEARCH" = "Search all posts"; "TOPICS.ALL_POSTS" = "All Posts"; diff --git a/Discussion/Discussion/uk.lproj/Localizable.strings b/Discussion/Discussion/uk.lproj/Localizable.strings index f54f4796f..923dea01d 100644 --- a/Discussion/Discussion/uk.lproj/Localizable.strings +++ b/Discussion/Discussion/uk.lproj/Localizable.strings @@ -7,6 +7,7 @@ */ "TITLE" = "Дискусії"; +"BANNER.DISCUSSIONS_IS_DISABLED" = "Posting in discussions is disabled by the course team"; "TOPICS.SEARCH" = "Пошук по всім постам"; "TOPICS.ALL_POSTS" = "Всі пости"; From 84896f6446c3981fa3eac97b0a2c7f7f63d52206 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Wed, 20 Mar 2024 16:47:06 +0100 Subject: [PATCH 080/136] style: changed text color for selected segmented control --- Core/Core/Extensions/UIApplicationExtension.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Core/Core/Extensions/UIApplicationExtension.swift b/Core/Core/Extensions/UIApplicationExtension.swift index 00b7651fe..1224eabb5 100644 --- a/Core/Core/Extensions/UIApplicationExtension.swift +++ b/Core/Core/Extensions/UIApplicationExtension.swift @@ -53,12 +53,20 @@ extension UINavigationController { .font: Theme.UIFonts.titleMedium() ] + UISegmentedControl.appearance().setTitleTextAttributes( + [ + .foregroundColor: Theme.Colors.textPrimary.uiColor(), + .font: Theme.UIFonts.labelLarge() + ], + for: .normal + ) UISegmentedControl.appearance().setTitleTextAttributes( [ .foregroundColor: Theme.Colors.primaryButtonTextColor.uiColor(), .font: Theme.UIFonts.labelLarge() ], - for: .normal) + for: .selected + ) UISegmentedControl.appearance().selectedSegmentTintColor = UIColor(Theme.Colors.accentXColor) UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = Theme.UIColors.accentXColor From 3531fc27a2e64d25e50f20dafc1353f15c43d52a Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Thu, 21 Mar 2024 14:23:15 +0500 Subject: [PATCH 081/136] feat: implementation / enhancements of analytics (#348) * feat: analytics setup, firebase formatter, analytics implimentation * chore: rename open edX already implemented for naming consistency * chore: regenerate test mocks * fix: typo fixes * fix: fix test cases * revert: revert accidentally pushed changes * refactor: address review feedback --- .../Presentation/AuthorizationAnalytics.swift | 28 +- .../Presentation/Login/SignInViewModel.swift | 2 +- .../Registration/SignUpView.swift | 2 +- .../Registration/SignUpViewModel.swift | 8 +- .../ResetPasswordViewModel.swift | 5 +- .../Presentation/Startup/StartupView.swift | 3 +- .../Startup/StartupViewModel.swift | 18 +- .../AuthorizationMock.generated.swift | 385 ++++++++++- Core/Core.xcodeproj/project.pbxproj | 12 + Core/Core/Analytics/CoreAnalytics.swift | 246 +++++++ Core/Core/Data/Model/UserSettings.swift | 15 +- Core/Core/Domain/Model/CourseBlockModel.swift | 6 +- .../View/Base/AppReview/AppReviewView.swift | 30 +- .../Base/AppReview/AppReviewViewModel.swift | 12 +- .../View/Base/LogistrationBottomView.swift | 4 + .../View/Base/VideoDownloadQualityView.swift | 32 +- .../Course/Data/Model/Data_CourseDates.swift | 13 + .../Container/CourseContainerView.swift | 3 +- .../Container/CourseContainerViewModel.swift | 55 +- .../Course/Presentation/CourseAnalytics.swift | 77 ++- .../Presentation/Dates/CourseDatesView.swift | 9 +- .../Dates/CourseDatesViewModel.swift | 66 +- .../Dates/DatesStatusInfoView.swift | 38 +- .../Presentation/Handouts/HandoutsView.swift | 13 +- .../Handouts/HandoutsViewModel.swift | 5 +- .../Outline/CourseOutlineView.swift | 19 +- .../CourseVideoDownloadBarView.swift | 6 +- .../CourseVideoDownloadBarViewModel.swift | 10 +- .../VideoDownloadQualityContainerView.swift | 7 +- .../Video/EncodedVideoPlayer.swift | 1 + .../Video/EncodedVideoPlayerViewModel.swift | 2 +- Course/CourseTests/CourseMock.generated.swift | 441 ++++++++++++- .../CourseContainerViewModelTests.swift | 39 +- .../Unit/CourseDateViewModelTests.swift | 9 +- .../Unit/HandoutsViewModelTests.swift | 72 +- .../DashboardMock.generated.swift | 275 ++++++++ .../Presentation/DiscoveryAnalytics.swift | 7 + .../DiscoveryWebviewViewModel.swift | 14 +- .../DiscoveryMock.generated.swift | 333 ++++++++++ .../DiscussionMock.generated.swift | 275 ++++++++ OpenEdX/DI/AppAssembly.swift | 8 + OpenEdX/DI/ScreenAssembly.swift | 19 +- .../AnalyticsManager/AnalyticsManager.swift | 619 +++++++++++++----- .../FirebaseAnalyticsService.swift | 72 +- .../SegmentAnalyticsService.swift | 2 +- OpenEdX/RouteController.swift | 17 +- OpenEdX/Router.swift | 20 +- .../Profile/Domain/Model/ProfileType.swift | 4 + .../DeleteAccount/DeleteAccountView.swift | 3 +- .../DeleteAccountViewModel.swift | 13 +- .../EditProfile/EditProfileViewModel.swift | 2 + .../Presentation/Profile/ProfileView.swift | 1 + .../Profile/ProfileViewModel.swift | 16 + .../Subviews/ProfileSupportInfoView.swift | 52 +- .../Presentation/ProfileAnalytics.swift | 17 + .../Profile/Presentation/ProfileRouter.swift | 6 +- .../Presentation/Settings/SettingsView.swift | 6 +- .../Settings/SettingsViewModel.swift | 4 +- .../Settings/VideoQualityView.swift | 13 +- .../DeleteAccountViewModelTests.swift | 35 +- .../ProfileTests/ProfileMock.generated.swift | 431 +++++++++++- WhatsNew/WhatsNew.xcodeproj/project.pbxproj | 4 + .../Presentation/WhatsNewAnalytics.swift | 23 + .../WhatsNew/Presentation/WhatsNewView.swift | 13 +- .../Presentation/WhatsNewViewModel.swift | 25 +- .../Presentation/WhatsNewTests.swift | 10 +- .../WhatsNewMock.generated.swift | 211 +++++- 67 files changed, 3875 insertions(+), 368 deletions(-) create mode 100644 Core/Core/Analytics/CoreAnalytics.swift create mode 100644 WhatsNew/WhatsNew/Presentation/WhatsNewAnalytics.swift diff --git a/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift b/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift index 060560ba6..b59ebd774 100644 --- a/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift +++ b/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift @@ -14,7 +14,7 @@ public enum AuthMethod: Equatable { public var analyticsValue: String { switch self { case .password: - "Password" + "password" case .socailAuth(let socialAuthMethod): socialAuthMethod.rawValue } @@ -22,31 +22,37 @@ public enum AuthMethod: Equatable { } public enum SocialAuthMethod: String { - case facebook = "Facebook" - case google = "Google" - case microsoft = "Microsoft" - case apple = "Apple" + case facebook = "facebook" + case google = "google" + case microsoft = "microsoft" + case apple = "apple" } //sourcery: AutoMockable public protocol AuthorizationAnalytics { func identify(id: String, username: String, email: String) func userLogin(method: AuthMethod) - func signUpClicked() + func registerClicked() + func signInClicked() + func userSignInClicked() func createAccountClicked() - func registrationSuccess() + func registrationSuccess(method: String) func forgotPasswordClicked() - func resetPasswordClicked(success: Bool) + func resetPasswordClicked() + func resetPassword(success: Bool) } #if DEBUG class AuthorizationAnalyticsMock: AuthorizationAnalytics { func identify(id: String, username: String, email: String) {} public func userLogin(method: AuthMethod) {} - public func signUpClicked() {} + public func registerClicked() {} + public func signInClicked() {} + public func userSignInClicked() {} public func createAccountClicked() {} - public func registrationSuccess() {} + public func registrationSuccess(method: String) {} public func forgotPasswordClicked() {} - public func resetPasswordClicked(success: Bool) {} + public func resetPasswordClicked() {} + public func resetPassword(success: Bool) {} } #endif diff --git a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift index deebec363..041c98ca7 100644 --- a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift +++ b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift @@ -75,7 +75,7 @@ public class SignInViewModel: ObservableObject { errorMessage = AuthLocalization.Error.invalidPasswordLenght return } - + analytics.userSignInClicked() isShowProgress = true do { let user = try await interactor.login(username: username, password: password) diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index 999f5d2b0..aa2a089ea 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -139,7 +139,7 @@ public struct SignUpView: View { StyledButton(AuthLocalization.SignUp.createAccountBtn) { viewModel.thirdPartyAuthSuccess = false Task { - await viewModel.registerUser() + await viewModel.registerUser(authMetod: viewModel.authMethod) } viewModel.trackCreateAccountClicked() } diff --git a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift index 62c29c6f0..1f57b8c02 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift @@ -54,6 +54,7 @@ public class SignUpViewModel: ObservableObject { private let interactor: AuthInteractorProtocol private let analytics: AuthorizationAnalytics private let validator: Validator + var authMethod: AuthMethod = .password public init( interactor: AuthInteractorProtocol, @@ -121,7 +122,7 @@ public class SignUpViewModel: ObservableObject { private var backend: String? @MainActor - func registerUser() async { + func registerUser(authMetod: AuthMethod = .password) async { do { let validateFields = configureFields() let errors = try await interactor.validateRegistrationFields(fields: validateFields) @@ -132,7 +133,7 @@ public class SignUpViewModel: ObservableObject { isSocial: externalToken != nil ) analytics.identify(id: "\(user.id)", username: user.username, email: user.email) - analytics.registrationSuccess() + analytics.registrationSuccess(method: authMetod.analyticsValue) isShowProgress = false router.showMainOrWhatsNewScreen(sourceScreen: sourceScreen) @@ -198,7 +199,8 @@ public class SignUpViewModel: ObservableObject { self.backend = backend thirdPartyAuthSuccess = true isShowProgress = false - await registerUser() + self.authMethod = authMethod + await registerUser(authMetod: authMethod) } } diff --git a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordViewModel.swift b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordViewModel.swift index cf2b1d71a..10b2edc00 100644 --- a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordViewModel.swift +++ b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordViewModel.swift @@ -50,14 +50,15 @@ public class ResetPasswordViewModel: ObservableObject { return } isShowProgress = true + analytics.resetPasswordClicked() do { _ = try await interactor.resetPassword(email: email).responseText.hideHtmlTagsAndUrls() isRecovered.wrappedValue.toggle() - analytics.resetPasswordClicked(success: true) + analytics.resetPassword(success: true) isShowProgress = false } catch { isShowProgress = false - analytics.resetPasswordClicked(success: false) + analytics.resetPassword(success: false) if let validationError = error.validationError, let value = validationError.data?["value"] as? String { errorMessage = value diff --git a/Authorization/Authorization/Presentation/Startup/StartupView.swift b/Authorization/Authorization/Presentation/Startup/StartupView.swift index bf5313947..517cb365e 100644 --- a/Authorization/Authorization/Presentation/Startup/StartupView.swift +++ b/Authorization/Authorization/Presentation/Startup/StartupView.swift @@ -128,7 +128,8 @@ public struct StartupView: View { struct StartupView_Previews: PreviewProvider { static var previews: some View { let vm = StartupViewModel( - router: AuthorizationRouterMock() + router: AuthorizationRouterMock(), + analytics: CoreAnalyticsMock() ) StartupView(viewModel: vm) diff --git a/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift b/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift index 1549940a1..650ae5f7f 100644 --- a/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift +++ b/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift @@ -10,11 +10,27 @@ import Core public class StartupViewModel: ObservableObject { let router: AuthorizationRouter + let analytics: CoreAnalytics + @Published var searchQuery: String? public init( - router: AuthorizationRouter + router: AuthorizationRouter, + analytics: CoreAnalytics ) { self.router = router + self.analytics = analytics + } + + func logAnalytics(searchQuery: String?) { + if let searchQuery { + analytics.trackEvent( + .logistrationCoursesSearch, + biValue: .logistrationCoursesSearch, + parameters: [EventParamKey.searchQuery: searchQuery] + ) + } else { + analytics.trackEvent(.logistrationExploreAllCourses, biValue: .logistrationExploreAllCourses) + } } } diff --git a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift index e6c3b9580..b31d6c690 100644 --- a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift +++ b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift @@ -521,9 +521,21 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { perform?(`method`) } - open func signUpClicked() { - addInvocation(.m_signUpClicked) - let perform = methodPerformValue(.m_signUpClicked) as? () -> Void + open func registerClicked() { + addInvocation(.m_registerClicked) + let perform = methodPerformValue(.m_registerClicked) as? () -> Void + perform?() + } + + open func signInClicked() { + addInvocation(.m_signInClicked) + let perform = methodPerformValue(.m_signInClicked) as? () -> Void + perform?() + } + + open func userSignInClicked() { + addInvocation(.m_userSignInClicked) + let perform = methodPerformValue(.m_userSignInClicked) as? () -> Void perform?() } @@ -533,10 +545,10 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { perform?() } - open func registrationSuccess() { - addInvocation(.m_registrationSuccess) - let perform = methodPerformValue(.m_registrationSuccess) as? () -> Void - perform?() + open func registrationSuccess(method: String) { + addInvocation(.m_registrationSuccess__method_method(Parameter.value(`method`))) + let perform = methodPerformValue(.m_registrationSuccess__method_method(Parameter.value(`method`))) as? (String) -> Void + perform?(`method`) } open func forgotPasswordClicked() { @@ -545,9 +557,15 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { perform?() } - open func resetPasswordClicked(success: Bool) { - addInvocation(.m_resetPasswordClicked__success_success(Parameter.value(`success`))) - let perform = methodPerformValue(.m_resetPasswordClicked__success_success(Parameter.value(`success`))) as? (Bool) -> Void + open func resetPasswordClicked() { + addInvocation(.m_resetPasswordClicked) + let perform = methodPerformValue(.m_resetPasswordClicked) as? () -> Void + perform?() + } + + open func resetPassword(success: Bool) { + addInvocation(.m_resetPassword__success_success(Parameter.value(`success`))) + let perform = methodPerformValue(.m_resetPassword__success_success(Parameter.value(`success`))) as? (Bool) -> Void perform?(`success`) } @@ -555,11 +573,14 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { fileprivate enum MethodType { case m_identify__id_idusername_usernameemail_email(Parameter, Parameter, Parameter) case m_userLogin__method_method(Parameter) - case m_signUpClicked + case m_registerClicked + case m_signInClicked + case m_userSignInClicked case m_createAccountClicked - case m_registrationSuccess + case m_registrationSuccess__method_method(Parameter) case m_forgotPasswordClicked - case m_resetPasswordClicked__success_success(Parameter) + case m_resetPasswordClicked + case m_resetPassword__success_success(Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { @@ -575,15 +596,24 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsMethod, rhs: rhsMethod, with: matcher), lhsMethod, rhsMethod, "method")) return Matcher.ComparisonResult(results) - case (.m_signUpClicked, .m_signUpClicked): return .match + case (.m_registerClicked, .m_registerClicked): return .match + + case (.m_signInClicked, .m_signInClicked): return .match + + case (.m_userSignInClicked, .m_userSignInClicked): return .match case (.m_createAccountClicked, .m_createAccountClicked): return .match - case (.m_registrationSuccess, .m_registrationSuccess): return .match + case (.m_registrationSuccess__method_method(let lhsMethod), .m_registrationSuccess__method_method(let rhsMethod)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsMethod, rhs: rhsMethod, with: matcher), lhsMethod, rhsMethod, "method")) + return Matcher.ComparisonResult(results) case (.m_forgotPasswordClicked, .m_forgotPasswordClicked): return .match - case (.m_resetPasswordClicked__success_success(let lhsSuccess), .m_resetPasswordClicked__success_success(let rhsSuccess)): + case (.m_resetPasswordClicked, .m_resetPasswordClicked): return .match + + case (.m_resetPassword__success_success(let lhsSuccess), .m_resetPassword__success_success(let rhsSuccess)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSuccess, rhs: rhsSuccess, with: matcher), lhsSuccess, rhsSuccess, "success")) return Matcher.ComparisonResult(results) @@ -595,22 +625,28 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { switch self { case let .m_identify__id_idusername_usernameemail_email(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case let .m_userLogin__method_method(p0): return p0.intValue - case .m_signUpClicked: return 0 + case .m_registerClicked: return 0 + case .m_signInClicked: return 0 + case .m_userSignInClicked: return 0 case .m_createAccountClicked: return 0 - case .m_registrationSuccess: return 0 + case let .m_registrationSuccess__method_method(p0): return p0.intValue case .m_forgotPasswordClicked: return 0 - case let .m_resetPasswordClicked__success_success(p0): return p0.intValue + case .m_resetPasswordClicked: return 0 + case let .m_resetPassword__success_success(p0): return p0.intValue } } func assertionName() -> String { switch self { case .m_identify__id_idusername_usernameemail_email: return ".identify(id:username:email:)" case .m_userLogin__method_method: return ".userLogin(method:)" - case .m_signUpClicked: return ".signUpClicked()" + case .m_registerClicked: return ".registerClicked()" + case .m_signInClicked: return ".signInClicked()" + case .m_userSignInClicked: return ".userSignInClicked()" case .m_createAccountClicked: return ".createAccountClicked()" - case .m_registrationSuccess: return ".registrationSuccess()" + case .m_registrationSuccess__method_method: return ".registrationSuccess(method:)" case .m_forgotPasswordClicked: return ".forgotPasswordClicked()" - case .m_resetPasswordClicked__success_success: return ".resetPasswordClicked(success:)" + case .m_resetPasswordClicked: return ".resetPasswordClicked()" + case .m_resetPassword__success_success: return ".resetPassword(success:)" } } } @@ -631,11 +667,14 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { public static func identify(id: Parameter, username: Parameter, email: Parameter) -> Verify { return Verify(method: .m_identify__id_idusername_usernameemail_email(`id`, `username`, `email`))} public static func userLogin(method: Parameter) -> Verify { return Verify(method: .m_userLogin__method_method(`method`))} - public static func signUpClicked() -> Verify { return Verify(method: .m_signUpClicked)} + public static func registerClicked() -> Verify { return Verify(method: .m_registerClicked)} + public static func signInClicked() -> Verify { return Verify(method: .m_signInClicked)} + public static func userSignInClicked() -> Verify { return Verify(method: .m_userSignInClicked)} public static func createAccountClicked() -> Verify { return Verify(method: .m_createAccountClicked)} - public static func registrationSuccess() -> Verify { return Verify(method: .m_registrationSuccess)} + public static func registrationSuccess(method: Parameter) -> Verify { return Verify(method: .m_registrationSuccess__method_method(`method`))} public static func forgotPasswordClicked() -> Verify { return Verify(method: .m_forgotPasswordClicked)} - public static func resetPasswordClicked(success: Parameter) -> Verify { return Verify(method: .m_resetPasswordClicked__success_success(`success`))} + public static func resetPasswordClicked() -> Verify { return Verify(method: .m_resetPasswordClicked)} + public static func resetPassword(success: Parameter) -> Verify { return Verify(method: .m_resetPassword__success_success(`success`))} } public struct Perform { @@ -648,20 +687,29 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { public static func userLogin(method: Parameter, perform: @escaping (AuthMethod) -> Void) -> Perform { return Perform(method: .m_userLogin__method_method(`method`), performs: perform) } - public static func signUpClicked(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_signUpClicked, performs: perform) + public static func registerClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_registerClicked, performs: perform) + } + public static func signInClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_signInClicked, performs: perform) + } + public static func userSignInClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_userSignInClicked, performs: perform) } public static func createAccountClicked(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_createAccountClicked, performs: perform) } - public static func registrationSuccess(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_registrationSuccess, performs: perform) + public static func registrationSuccess(method: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_registrationSuccess__method_method(`method`), performs: perform) } public static func forgotPasswordClicked(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_forgotPasswordClicked, performs: perform) } - public static func resetPasswordClicked(success: Parameter, perform: @escaping (Bool) -> Void) -> Perform { - return Perform(method: .m_resetPasswordClicked__success_success(`success`), performs: perform) + public static func resetPasswordClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_resetPasswordClicked, performs: perform) + } + public static func resetPassword(success: Parameter, perform: @escaping (Bool) -> Void) -> Perform { + return Perform(method: .m_resetPassword__success_success(`success`), performs: perform) } } @@ -1858,6 +1906,281 @@ open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { } } +// MARK: - CoreAnalytics + +open class CoreAnalyticsMock: CoreAnalytics, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func trackEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, [String: Any]?) -> Void + perform?(`event`, `parameters`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void + perform?(`event`, `biValue`, `parameters`) + } + + open func appreview(_ event: AnalyticsEvent, biValue: EventBIValue, action: String?, rating: Int?) { + addInvocation(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) + let perform = methodPerformValue(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) as? (AnalyticsEvent, EventBIValue, String?, Int?) -> Void + perform?(`event`, `biValue`, `action`, `rating`) + } + + open func videoQualityChanged(_ event: AnalyticsEvent, bivalue: EventBIValue, value: String, oldValue: String) { + addInvocation(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) + let perform = methodPerformValue(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) as? (AnalyticsEvent, EventBIValue, String, String) -> Void + perform?(`event`, `bivalue`, `value`, `oldValue`) + } + + open func trackEvent(_ event: AnalyticsEvent) { + addInvocation(.m_trackEvent__event(Parameter.value(`event`))) + let perform = methodPerformValue(.m_trackEvent__event(Parameter.value(`event`))) as? (AnalyticsEvent) -> Void + perform?(`event`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void + perform?(`event`, `biValue`) + } + + + fileprivate enum MethodType { + case m_trackEvent__eventparameters_parameters(Parameter, Parameter<[String: Any]?>) + case m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter, Parameter, Parameter<[String: Any]?>) + case m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter, Parameter, Parameter, Parameter) + case m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter, Parameter, Parameter, Parameter) + case m_trackEvent__event(Parameter) + case m_trackEvent__eventbiValue_biValue(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_trackEvent__eventparameters_parameters(let lhsEvent, let lhsParameters), .m_trackEvent__eventparameters_parameters(let rhsEvent, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValueparameters_parameters(let lhsEvent, let lhsBivalue, let lhsParameters), .m_trackEvent__eventbiValue_biValueparameters_parameters(let rhsEvent, let rhsBivalue, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_appreview__eventbiValue_biValueaction_actionrating_rating(let lhsEvent, let lhsBivalue, let lhsAction, let lhsRating), .m_appreview__eventbiValue_biValueaction_actionrating_rating(let rhsEvent, let rhsBivalue, let rhsAction, let rhsRating)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRating, rhs: rhsRating, with: matcher), lhsRating, rhsRating, "rating")) + return Matcher.ComparisonResult(results) + + case (.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let lhsEvent, let lhsBivalue, let lhsValue, let lhsOldvalue), .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let rhsEvent, let rhsBivalue, let rhsValue, let rhsOldvalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "bivalue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsValue, rhs: rhsValue, with: matcher), lhsValue, rhsValue, "value")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOldvalue, rhs: rhsOldvalue, with: matcher), lhsOldvalue, rhsOldvalue, "oldValue")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__event(let lhsEvent), .m_trackEvent__event(let rhsEvent)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_trackEvent__eventbiValue_biValue(let rhsEvent, let rhsBivalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_trackEvent__eventparameters_parameters(p0, p1): return p0.intValue + p1.intValue + case let .m_trackEvent__eventbiValue_biValueparameters_parameters(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_appreview__eventbiValue_biValueaction_actionrating_rating(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_trackEvent__event(p0): return p0.intValue + case let .m_trackEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_trackEvent__eventparameters_parameters: return ".trackEvent(_:parameters:)" + case .m_trackEvent__eventbiValue_biValueparameters_parameters: return ".trackEvent(_:biValue:parameters:)" + case .m_appreview__eventbiValue_biValueaction_actionrating_rating: return ".appreview(_:biValue:action:rating:)" + case .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue: return ".videoQualityChanged(_:bivalue:value:oldValue:)" + case .m_trackEvent__event: return ".trackEvent(_:)" + case .m_trackEvent__eventbiValue_biValue: return ".trackEvent(_:biValue:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`))} + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter) -> Verify { return Verify(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`))} + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter) -> Verify { return Verify(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`))} + public static func trackEvent(_ event: Parameter) -> Verify { return Verify(method: .m_trackEvent__event(`event`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`), performs: perform) + } + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String?, Int?) -> Void) -> Perform { + return Perform(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`), performs: perform) + } + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String, String) -> Void) -> Perform { + return Perform(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`), performs: perform) + } + public static func trackEvent(_ event: Parameter, perform: @escaping (AnalyticsEvent) -> Void) -> Perform { + return Perform(method: .m_trackEvent__event(`event`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + // MARK: - DownloadManagerProtocol open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 0f3115b5f..3da8c0ff2 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -127,6 +127,7 @@ 07E0939F2B308D2800F1E4B2 /* Data_Certificate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07E0939E2B308D2800F1E4B2 /* Data_Certificate.swift */; }; 141F1D302B7328D4009E81EB /* WebviewCookiesUpdateProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 141F1D2F2B7328D4009E81EB /* WebviewCookiesUpdateProtocol.swift */; }; 142EDD6C2B831D1400F9F320 /* BranchSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 142EDD6B2B831D1400F9F320 /* BranchSDK */; }; + 14769D3C2B9822EE00AB36D4 /* CoreAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14769D3B2B9822EE00AB36D4 /* CoreAnalytics.swift */; }; A51CDBE72B6D21F2009B6D4E /* SegmentConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51CDBE62B6D21F2009B6D4E /* SegmentConfig.swift */; }; A53A32352B233DEC005FE38A /* ThemeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A32342B233DEC005FE38A /* ThemeConfig.swift */; }; A595689B2B6173DF00ED4F90 /* BranchConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A595689A2B6173DF00ED4F90 /* BranchConfig.swift */; }; @@ -301,6 +302,7 @@ 07E0939E2B308D2800F1E4B2 /* Data_Certificate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_Certificate.swift; sourceTree = ""; }; 0E13E9173C9C4CFC19F8B6F2 /* Pods-App-Core.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debugstage.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debugstage.xcconfig"; sourceTree = ""; }; 141F1D2F2B7328D4009E81EB /* WebviewCookiesUpdateProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebviewCookiesUpdateProtocol.swift; sourceTree = ""; }; + 14769D3B2B9822EE00AB36D4 /* CoreAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreAnalytics.swift; sourceTree = ""; }; 1A154A95AF4EE85A4A1C083B /* Pods-App-Core.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.releasedev.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.releasedev.xcconfig"; sourceTree = ""; }; 2B7E6FE7843FC4CF2BFA712D /* Pods-App-Core.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debug.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debug.xcconfig"; sourceTree = ""; }; 349B90CD6579F7B8D257E515 /* Pods_App_Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -620,6 +622,7 @@ 0770DE0A28D07831006D8A5D /* Core */ = { isa = PBXGroup; children = ( + 14769D3A2B9822D900AB36D4 /* Analytics */, BA8FA65F2AD5973500EA029A /* Providers */, 027BD3A12909470F00392132 /* AvoidingHelpers */, 0770DE5528D0B142006D8A5D /* SwiftGen */, @@ -711,6 +714,14 @@ path = Base; sourceTree = ""; }; + 14769D3A2B9822D900AB36D4 /* Analytics */ = { + isa = PBXGroup; + children = ( + 14769D3B2B9822EE00AB36D4 /* CoreAnalytics.swift */, + ); + path = Analytics; + sourceTree = ""; + }; BA30427C2B20B235009B64B7 /* SocialAuth */ = { isa = PBXGroup; children = ( @@ -1068,6 +1079,7 @@ 070019AE28F701B200D5FC78 /* Certificate.swift in Sources */, BA4AFB442B6A5AF100A21367 /* CheckBoxView.swift in Sources */, 076F297F2A1F80C800967E7D /* Pagination.swift in Sources */, + 14769D3C2B9822EE00AB36D4 /* CoreAnalytics.swift in Sources */, 02AFCC1A2AEFDC18000360F0 /* ThirdPartyMailer.swift in Sources */, 0770DE5F28D0B22C006D8A5D /* Strings.swift in Sources */, BA981BCE2B8F5C49005707C2 /* Sequence+Extensions.swift in Sources */, diff --git a/Core/Core/Analytics/CoreAnalytics.swift b/Core/Core/Analytics/CoreAnalytics.swift new file mode 100644 index 000000000..57f7e49d4 --- /dev/null +++ b/Core/Core/Analytics/CoreAnalytics.swift @@ -0,0 +1,246 @@ +// +// CoreAnalytics.swift +// Core +// +// Created by Saeed Bashir on 3/6/24. +// + +import Foundation + +//sourcery: AutoMockable +public protocol CoreAnalytics { + func trackEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) + func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) + func appreview(_ event: AnalyticsEvent, biValue: EventBIValue, action: String?, rating: Int?) + func videoQualityChanged( + _ event: AnalyticsEvent, + bivalue: EventBIValue, + value: String, + oldValue: String + ) +} + +public extension CoreAnalytics { + func trackEvent(_ event: AnalyticsEvent) { + trackEvent(event, parameters: nil) + } + + func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + trackEvent(event, biValue: biValue, parameters: nil) + } +} + +#if DEBUG +public class CoreAnalyticsMock: CoreAnalytics { + public init() {} + public func trackEvent(_ event: AnalyticsEvent, parameters: [String: Any]? = nil) {} + public func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) {} + public func appreview(_ event: AnalyticsEvent, biValue: EventBIValue, action: String? = nil, rating: Int? = 0) {} + public func videoQualityChanged( + _ event: AnalyticsEvent, + bivalue: EventBIValue, + value: String, + oldValue: String + ) {} +} +#endif + +public enum AnalyticsEvent: String { + case launch = "Launch" + case logistrationCoursesSearch = "Logistration:Courses Search" + case logistrationExploreAllCourses = "Logistration:Explore All Courses" + case userLogin = "Logistration:Sign In Success" + case registerClicked = "Logistration:Register Clicked" + case signInClicked = "Logistration:Sign In Clicked" + case userSignInClicked = "Logistration:User Sign In Clicked" + case createAccountClicked = "Logistration:Create Account Clicked" + case registrationSuccess = "Logistration:Register Success" + case userLogout = "Profile:Logged Out" + case userLogoutClicked = "Profile:Logout Clicked" + case forgotPasswordClicked = "Logistration:Forgot Password Clicked" + case resetPasswordClicked = "Logistration:Reset Password Clicked" + case resetPasswordSuccess = "Logistration:Reset Password Success" + case mainDiscoveryTabClicked = "MainDashboard:Discover" + case mainDashboardTabClicked = "MainDashboard:My Courses" + case mainProgramsTabClicked = "MainDashboard:My Programs" + case mainProfileTabClicked = "MainDashboard:Profile" + case discoverySearchBarClicked = "Discovery:Search Bar Clicked" + case discoveryCoursesSearch = "Discovery:Courses Search" + case discoveryCourseClicked = "Discovery:Course Clicked" + case discoveryProgramInfo = "Discovery:Program Info" + case dashboardCourseClicked = "Course:Dashboard" + case profileEditClicked = "Profile:Edit Clicked" + case profileSwitch = "Profile:Switch Profile" + case profileWifiToggle = "Profile:Wifi Toggle" + case profileEditDoneClicked = "Profile:Edit Done Clicked" + case profileDeleteAccountClicked = "Profile:Delete Account Clicked" + case profileUserDeleteAccountClicked = "Profile:User Delete Account Clicked" + case profileDeleteAccountSuccess = "Profile:Delete Account Success" + case videoStreamQualityChanged = "Video:Streaming Quality Changed" + case videoDownloadQualityChanged = "Video:Download Quality Changed" + case profileVideoSettingsClicked = "Profile:Video Setting Clicked" + case privacyPolicyClicked = "Profile:Privacy Policy Clicked" + case cookiePolicyClicked = "Profile:Cookie Policy Clicked" + case emailSupportClicked = "Profile:Contact Support Clicked" + case faqClicked = "Profile:FAQ Clicked" + case tosClicked = "Profile:Terms of Use Clicked" + case dataSellClicked = "Profile:Data Sell Clicked" + case courseEnrollClicked = "Discovery:Course Enroll Clicked" + case courseEnrollSuccess = "Discovery:Course Enroll Success" + case externalLinkOpenAlert = "External:Link Opening Alert" + case externalLinkOpenAlertAction = "External:Link Opening Alert Action" + case viewCourseClicked = "Discovery:Course Info" + case resumeCourseClicked = "Course:Resume Course Clicked" + case sequentialClicked = "Course:Sequential Clicked" + case verticalClicked = "Course:Unit Detail" + case nextBlockClicked = "Course:Next Block Clicked" + case prevBlockClicked = "Course:Prev Block Clicked" + case finishVerticalClicked = "Course:Unit Finished Clicked" + case finishVerticalNextSectionClicked = "Course:Finish Unit Next Unit Clicked" + case finishVerticalBackToOutlineClicked = "Course:Unit Finish Back To Outline Clicked" + case courseOutlineCourseTabClicked = "Course:Home Tab" + case courseOutlineVideosTabClicked = "Course:Videos Tab" + case courseOutlineDatesTabClicked = "Course:Dates Tab" + case courseOutlineDiscussionTabClicked = "Course:Discussion Tab" + case courseOutlineHandoutsTabClicked = "Course:Handouts Tab" + case datesComponentClicked = "Dates:Course Component Clicked" + case plsBannerViewed = "PLS:Banner Viewed" + case plsShiftDatesClicked = "PLS:Shift Button Clicked" + case plsShiftDatesSuccess = "PLS:Shift Dates Success" + case courseViewCertificateClicked = "Course:View Certificate Clicked" + case bulkDownloadVideosToggle = "Video:Bulk Download Toggle" + case bulkDownloadVideosSubsection = "Video:Bulk Download Subsection" + case bulkDeleteVideosSubsection = "Videos:Delete Subsection Videos" + case discussionAllPostsClicked = "Discussion:All Posts Clicked" + case discussionFollowingClicked = "Discussion:Following Posts Clicked" + case discussionTopicClicked = "Discussion:Topic Clicked" + case appreviewPopupViewed = "AppReviews:Rating Dialog Viewed" + case appreviewPopupAction = "AppReviews:Rating Dialog Action" + case courseAnnouncement = "Course:Announcements" + case courseHandouts = "Course:Handouts" + case whatnewPopup = "WhatsNew:Pop up Viewed" + case whatnewDone = "WhatsNew:Done" + case whatnewClose = "WhatsNew:Close" +} + +public enum EventBIValue: String { + case launch = "edx.bi.app.launch" + case logistrationCoursesSearch = "edx.bi.app.logistration.courses_search" + case logistrationExploreAllCourses = "edx.bi.app.logistration.explore.all.courses" + case userLogin = "edx.bi.app.user.signin.success" + case signInClicked = "edx.bi.app.logistration.signin.clicked" + case registerClicked = "edx.bi.app.logistration.register.clicked" + case registrationSuccess = "edx.bi.app.user.register.success" + case userSignInClicked = "edx.bi.app.logistration.user.signin.clicked" + case createAccountClicked = "edx.bi.app.logistration.user.create_account.clicked" + case forgotPasswordClicked = "edx.bi.app.logistration.forgot_password.clicked" + case resetPasswordClicked = "edx.bi.app.user.reset_password.clicked" + case resetPasswordSuccess = "edx.bi.app.user.reset_password.success" + case courseEnrollClicked = "edx.bi.app.course.enroll.clicked" + case courseEnrollSuccess = "edx.bi.app.course.enroll.success" + case externalLinkOpenAlert = "edx.bi.app.discovery.external_link.opening.alert" + case externalLinkOpenAlertAction = "edx.bi.app.discovery.external_link.opening.alert_action" + case viewCourseClicked = "edx.bi.app.course.info" + case resumeCourseClicked = "edx.bi.app.course.resume_course.clicked" + case mainDiscoveryTabClicked = "edx.bi.app.main_dashboard.discover" + case mainDashboardTabClicked = "edx.bi.app.main_dashboard.my_course" + case mainProgramsTabClicked = "edx.bi.app.main_dashboard.my_program" + case mainProfileTabClicked = "edx.bi.app.main_dashboard.profile" + case profileEditClicked = "edx.bi.app.profile.edit.clicked" + case profileEditDoneClicked = "edx.bi.app.profile.edit_done.clicked" + case profileVideoSettingsClicked = "edx.bi.app.profile.video_setting.clicked" + case emailSupportClicked = "edx.bi.app.profile.email_support.clicked" + case faqClicked = "edx.bi.app.profile.faq.clicked" + case tosClicked = "edx.bi.app.profile.terms_of_use.clicked" + case dataSellClicked = "edx.bi.app.profile.do_not_sell_data.clicked" + case privacyPolicyClicked = "edx.bi.app.profile.privacy_policy.clicked" + case cookiePolicyClicked = "edx.bi.app.profile.cookie_policy.clicked" + case profileDeleteAccountClicked = "edx.bi.app.profile.delete_account.clicked" + case userLogout = "edx.bi.app.user.logout" + case datesComponentClicked = "edx.bi.app.coursedates.component.clicked" + case plsBannerViewed = "edx.bi.app.dates.pls_banner.viewed" + case plsShiftDatesClicked = "edx.bi.app.dates.pls_banner.shift_dates.clicked" + case plsShiftDatesSuccess = "edx.bi.app.dates.pls_banner.shift_dates.success" + case courseViewCertificateClicked = "edx.bi.app.course.view_certificate.clicked" + case bulkDownloadVideosToggle = "edx.bi.app.videos.download.toggle" + case bulkDownloadVideosSubsection = "edx.bi.video.subsection.bulkdownload" + case bulkDeleteVideosSubsection = "edx.bi.app.video.delete.subsection" + case dashboardCourseClicked = "edx.bi.app.course.dashboard" + case courseOutlineVideosTabClicked = "edx.bi.app.course.video_tab" + case courseOutlineDatesTabClicked = "edx.bi.app.course.dates_tab" + case courseOutlineDiscussionTabClicked = "edx.bi.app.course.discussion_tab" + case courseOutlineHandoutsTabClicked = "edx.bi.app.course.handouts_tab" + case verticalClicked = "edx.bi.app.course.unit_detail" + case nextBlockClicked = "edx.bi.app.course.next_block.clicked" + case prevBlockClicked = "bi.app.course.prev_block.clicked" + case sequentialClicked = "edx.bi.app.course.sequential.clicked" + case finishVerticalClicked = "edx.bi.app.course.unit_finished.clicked" + case finishVerticalNextSectionClicked = "edx.bi.app.course.finish_unit.next_unit.clicked" + case finishVerticalBackToOutlineClicked = "edx.bi.app.course.finish_unit.back_to_outline.clicked" + case discoverySearchBarClicked = "edx.bi.app.discovery.search_bar.clicked" + case discoveryCoursesSearch = "edx.bi.app.discovery.courses_search" + case discoveryCourseClicked = "edx.bi.app.discovery.course.clicked" + case discussionAllPostsClicked = "edx.bi.app.discussion.all_posts.clicked" + case discussionFollowingClicked = "edx.bi.app.discussion.following_posts.clicked" + case discussionTopicClicked = "edx.bi.app.discussion.topic.clicked" + case discoveryProgramInfo = "edx.bi.app.discovery.program_info" + case userLogoutClicked = "edx.bi.app.profile.logout.clicked" + case courseOutlineCourseTabClicked = "edx.bi.app.course.home_tab" + case appreviewPopupViewed = "edx.bi.app.app_reviews.rating_dialog.viewed" + case appreviewPopupAction = "edx.bi.app.app_reviews.rating_dialog.action" + case profileSwitch = "edx.bi.app.profile.switch_profile.clicked" + case profileWifiToggle = "edx.bi.app.profile.wifi_toggle" + case profileUserDeleteAccountClicked = "edx.bi.app.profile.user.delete_account.clicked" + case profileDeleteAccountSuccess = "edx.bi.app.profile.delete_account.success" + case videoStreamQualityChanged = "edx.bi.app.video.streaming_quality.changed" + case videoDownloadQualityChanged = "edx.bi.app.video.download_quality.changed" + case courseAnnouncement = "edx.bi.app.course.announcements" + case courseHandouts = "edx.bi.app.course.handouts" + case whatnewPopup = "edx.bi.app.whats_new.popup.viewed" + case whatnewDone = "edx.bi.app.whats_new.done" + case whatnewClose = "edx.bi.app.whats_new.close" +} + +public struct EventParamKey { + public static let courseID = "course_id" + public static let courseName = "course_name" + public static let topicID = "topic_id" + public static let topicName = "topic_name" + public static let blockID = "block_id" + public static let blockName = "block_name" + public static let unitID = "unit_id" + public static let unitName = "unit_name" + public static let method = "method" + public static let label = "label" + public static let coursesCount = "courses_count" + public static let force = "force" + public static let success = "success" + public static let category = "category" + public static let appVersion = "app_version" + public static let name = "name" + public static let link = "link" + public static let url = "url" + public static let screenName = "screen_name" + public static let alertAction = "alert_action" + public static let action = "action" + public static let searchQuery = "search_query" + public static let value = "value" + public static let oldValue = "old_value" + public static let rating = "rating" + public static let bannerType = "banner_type" + public static let courseSection = "course_section" + public static let courseSubsection = "course_subsection" + public static let noOfVideos = "number_of_videos" + public static let supported = "supported" + public static let conversion = "conversion" +} + +public struct EventCategory { + public static let appreviews = "app-reviews" + public static let whatsNew = "whats_new" + public static let courseDates = "course_dates" + public static let discovery = "discovery" + public static let profile = "profile" + public static let video = "video" + public static let course = "course" +} diff --git a/Core/Core/Data/Model/UserSettings.swift b/Core/Core/Data/Model/UserSettings.swift index ab113a74e..1b25e9d6c 100644 --- a/Core/Core/Data/Model/UserSettings.swift +++ b/Core/Core/Data/Model/UserSettings.swift @@ -28,11 +28,20 @@ public enum StreamingQuality: Codable { case low case medium case high + + public var value: String? { + return String(describing: self).components(separatedBy: "(").first + } } public enum DownloadQuality: Codable, CaseIterable { case auto - case low_360 - case medium_540 - case high_720 + case low + case medium + case high + + public var value: String? { + return String(describing: self).components(separatedBy: "(").first + } + } diff --git a/Core/Core/Domain/Model/CourseBlockModel.swift b/Core/Core/Domain/Model/CourseBlockModel.swift index 9e3eb0b11..3d8ae7a4f 100644 --- a/Core/Core/Domain/Model/CourseBlockModel.swift +++ b/Core/Core/Domain/Model/CourseBlockModel.swift @@ -258,15 +258,15 @@ public struct CourseBlockEncodedVideo { [mobileLow, mobileHigh, desktopMP4, fallback, hls] .first(where: { $0?.isDownloadable == true })? .flatMap { $0 } - case .high_720: + case .high: [desktopMP4, mobileHigh, mobileLow, fallback, hls] .first(where: { $0?.isDownloadable == true })? .flatMap { $0 } - case .medium_540: + case .medium: [mobileHigh, mobileLow, desktopMP4, fallback, hls] .first(where: { $0?.isDownloadable == true })? .flatMap { $0 } - case .low_360: + case .low: [mobileLow, mobileHigh, desktopMP4, fallback, hls] .first(where: { $0?.isDownloadable == true })? .flatMap { $0 } diff --git a/Core/Core/View/Base/AppReview/AppReviewView.swift b/Core/Core/View/Base/AppReview/AppReviewView.swift index c22765245..176c9707a 100644 --- a/Core/Core/View/Base/AppReview/AppReviewView.swift +++ b/Core/Core/View/Base/AppReview/AppReviewView.swift @@ -26,6 +26,7 @@ public struct AppReviewView: View { .ignoresSafeArea() .onTapGesture { presentationMode.wrappedValue.dismiss() + viewModel.trackAppReviewAction("dismissed") } if viewModel.showSelectMailClientView { SelectMailClientView(clients: viewModel.clients, onMailTapped: { client in @@ -59,10 +60,14 @@ public struct AppReviewView: View { Text(CoreLocalization.Review.notNow) .font(Theme.Fonts.labelLarge) .foregroundColor(Theme.Colors.accentColor) - .onTapGesture { presentationMode.wrappedValue.dismiss() } + .onTapGesture { + viewModel.trackAppReviewAction("dismissed") + presentationMode.wrappedValue.dismiss() + } AppReviewButton(type: .submit, action: { viewModel.reviewAction() + viewModel.trackAppReviewAction("submit") }, isActive: .constant(viewModel.rating != 0)) } @@ -99,10 +104,14 @@ public struct AppReviewView: View { Text(CoreLocalization.Review.notNow) .font(Theme.Fonts.labelLarge) .foregroundColor(Theme.Colors.accentColor) - .onTapGesture { presentationMode.wrappedValue.dismiss() } + .onTapGesture { + viewModel.trackAppReviewAction("dismissed") + presentationMode.wrappedValue.dismiss() + } AppReviewButton(type: .shareFeedback, action: { viewModel.writeFeedbackToMail() + viewModel.trackAppReviewAction("share_feedback") }, isActive: .constant(viewModel.feedback.count >= 3)) } @@ -111,12 +120,16 @@ public struct AppReviewView: View { Text(CoreLocalization.Review.notNow) .font(Theme.Fonts.labelLarge) .foregroundColor(Theme.Colors.accentColor) - .onTapGesture { presentationMode.wrappedValue.dismiss() } + .onTapGesture { + viewModel.trackAppReviewAction("dismissed") + presentationMode.wrappedValue.dismiss() + } AppReviewButton(type: .rateUs, action: { presentationMode.wrappedValue.dismiss() SKStoreReviewController.requestReviewInCurrentScene() viewModel.storage.lastReviewDate = Date() + viewModel.trackAppReviewAction("rate_app") }, isActive: .constant(true)) } } @@ -135,13 +148,22 @@ public struct AppReviewView: View { .shadow(color: Color.black.opacity(0.4), radius: 12, x: 0, y: 0) } } + .onFirstAppear { + viewModel.trackAppReviewViewed() + } } } #if DEBUG struct AppReviewView_Previews: PreviewProvider { static var previews: some View { - AppReviewView(viewModel: AppReviewViewModel(config: ConfigMock(), storage: CoreStorageMock())) + AppReviewView( + viewModel: AppReviewViewModel( + config: ConfigMock(), + storage: CoreStorageMock(), + analytics: CoreAnalyticsMock() + ) + ) } } #endif diff --git a/Core/Core/View/Base/AppReview/AppReviewViewModel.swift b/Core/Core/View/Base/AppReview/AppReviewViewModel.swift index a14f78345..69026248c 100644 --- a/Core/Core/View/Base/AppReview/AppReviewViewModel.swift +++ b/Core/Core/View/Base/AppReview/AppReviewViewModel.swift @@ -51,10 +51,12 @@ public class AppReviewViewModel: ObservableObject { private let config: ConfigProtocol var storage: CoreStorage + private let analytics: CoreAnalytics - public init(config: ConfigProtocol, storage: CoreStorage) { + public init(config: ConfigProtocol, storage: CoreStorage, analytics: CoreAnalytics) { self.config = config self.storage = storage + self.analytics = analytics } public func shouldShowRatingView() -> Bool { @@ -152,4 +154,12 @@ public class AppReviewViewModel: ObservableObject { return false } + + func trackAppReviewAction(_ action: String? = nil) { + analytics.appreview(.appreviewPopupAction, biValue: .appreviewPopupAction, action: action, rating: rating) + } + + func trackAppReviewViewed() { + analytics.appreview(.appreviewPopupViewed, biValue: .appreviewPopupViewed, action: nil, rating: rating) + } } diff --git a/Core/Core/View/Base/LogistrationBottomView.swift b/Core/Core/View/Base/LogistrationBottomView.swift index 6795c62ea..5f01cc98a 100644 --- a/Core/Core/View/Base/LogistrationBottomView.swift +++ b/Core/Core/View/Base/LogistrationBottomView.swift @@ -15,6 +15,10 @@ public enum LogistrationSourceScreen: Equatable { case discovery case courseDetail(String, String) case programDetails(String) + + public var value: String? { + return String(describing: self).components(separatedBy: "(").first + } } public enum LogistrationAction { diff --git a/Core/Core/View/Base/VideoDownloadQualityView.swift b/Core/Core/View/Base/VideoDownloadQualityView.swift index 7585d4c57..7482a8d0d 100644 --- a/Core/Core/View/Base/VideoDownloadQualityView.swift +++ b/Core/Core/View/Base/VideoDownloadQualityView.swift @@ -13,7 +13,7 @@ public final class VideoDownloadQualityViewModel: ObservableObject { var didSelect: ((DownloadQuality) -> Void)? let downloadQuality = DownloadQuality.allCases - + @Published var selectedDownloadQuality: DownloadQuality { willSet { if newValue != selectedDownloadQuality { @@ -32,10 +32,12 @@ public struct VideoDownloadQualityView: View { @StateObject private var viewModel: VideoDownloadQualityViewModel + private var analytics: CoreAnalytics public init( downloadQuality: DownloadQuality, - didSelect: ((DownloadQuality) -> Void)? + didSelect: ((DownloadQuality) -> Void)?, + analytics: CoreAnalytics ) { self._viewModel = StateObject( wrappedValue: .init( @@ -43,6 +45,7 @@ public struct VideoDownloadQualityView: View { didSelect: didSelect ) ) + self.analytics = analytics } public var body: some View { @@ -52,6 +55,13 @@ public struct VideoDownloadQualityView: View { VStack(alignment: .leading, spacing: 24) { ForEach(viewModel.downloadQuality, id: \.self) { quality in Button(action: { + analytics.videoQualityChanged( + .videoDownloadQualityChanged, + bivalue: .videoDownloadQualityChanged, + value: quality.value ?? "", + oldValue: viewModel.selectedDownloadQuality.value ?? "" + ) + viewModel.selectedDownloadQuality = quality }, label: { HStack { @@ -127,11 +137,11 @@ public extension DownloadQuality { switch self { case .auto: return CoreLocalization.Settings.downloadQualityAutoTitle - case .low_360: + case .low: return CoreLocalization.Settings.downloadQuality360Title - case .medium_540: + case .medium: return CoreLocalization.Settings.downloadQuality540Title - case .high_720: + case .high: return CoreLocalization.Settings.downloadQuality720Title } } @@ -140,11 +150,11 @@ public extension DownloadQuality { switch self { case .auto: return CoreLocalization.Settings.downloadQualityAutoDescription - case .low_360: + case .low: return CoreLocalization.Settings.downloadQuality360Description - case .medium_540: + case .medium: return nil - case .high_720: + case .high: return CoreLocalization.Settings.downloadQuality720Description } } @@ -154,12 +164,12 @@ public extension DownloadQuality { case .auto: return CoreLocalization.Settings.downloadQualityAutoTitle + " (" + CoreLocalization.Settings.downloadQualityAutoDescription + ")" - case .low_360: + case .low: return CoreLocalization.Settings.downloadQuality360Title + " (" + CoreLocalization.Settings.downloadQuality360Description + ")" - case .medium_540: + case .medium: return CoreLocalization.Settings.downloadQuality540Title - case .high_720: + case .high: return CoreLocalization.Settings.downloadQuality720Title + " (" + CoreLocalization.Settings.downloadQuality720Description + ")" } diff --git a/Course/Course/Data/Model/Data_CourseDates.swift b/Course/Course/Data/Model/Data_CourseDates.swift index 62d493dc4..2ef5d339b 100644 --- a/Course/Course/Data/Model/Data_CourseDates.swift +++ b/Course/Course/Data/Model/Data_CourseDates.swift @@ -138,6 +138,19 @@ public extension DataLayer { "" } } + + var analyticsBannerType: String { + switch self { + case .datesTabInfoBanner: + "info" + case .upgradeToCompleteGradedBanner: + "upgrade_to_participate" + case .upgradeToResetBanner: + "upgrade_to_shift" + case .resetDatesBanner: + "shift_dates" + } + } } } diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 54c0cca35..dd881e0d2 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -199,7 +199,8 @@ struct CourseScreensView_Previews: PreviewProvider { courseStart: nil, courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ), courseID: "", title: "Title of Course") } diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index 85418cb27..c3259ed8c 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -90,7 +90,8 @@ public class CourseContainerViewModel: BaseCourseViewModel { private let interactor: CourseInteractorProtocol private let authInteractor: AuthInteractorProtocol - private let analytics: CourseAnalytics + let analytics: CourseAnalytics + let coreAnalytics: CoreAnalytics private(set) var storage: CourseStorage public init( @@ -106,7 +107,8 @@ public class CourseContainerViewModel: BaseCourseViewModel { courseStart: Date?, courseEnd: Date?, enrollmentStart: Date?, - enrollmentEnd: Date? + enrollmentEnd: Date?, + coreAnalytics: CoreAnalytics ) { self.interactor = interactor self.authInteractor = authInteractor @@ -122,6 +124,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { self.storage = storage self.userSettings = storage.userSettings self.isInternetAvaliable = connectivity.isInternetAvaliable + self.coreAnalytics = coreAnalytics super.init(manager: manager) addObservers() @@ -179,14 +182,32 @@ public class CourseContainerViewModel: BaseCourseViewModel { } @MainActor - func shiftDueDates(courseID: String, withProgress: Bool = true) async { + func shiftDueDates(courseID: String, withProgress: Bool = true, screen: DatesStatusInfoScreen, type: String) async { isShowProgress = withProgress do { try await interactor.shiftDueDates(courseID: courseID) NotificationCenter.default.post(name: .shiftCourseDates, object: courseID) isShowProgress = false + + analytics.plsSuccessEvent( + .plsShiftDatesSuccess, + bivalue: .plsShiftDatesSuccess, + courseID: courseID, + screenName: screen.rawValue, + type: type, + success: true + ) + } catch let error { isShowProgress = false + analytics.plsSuccessEvent( + .plsShiftDatesSuccess, + bivalue: .plsShiftDatesSuccess, + courseID: courseID, + screenName: screen.rawValue, + type: type, + success: false + ) if error.isInternetError || error is NoCachedDataError { errorMessage = CoreLocalization.Error.slowOrNoInternetConnection } else { @@ -227,6 +248,22 @@ public class CourseContainerViewModel: BaseCourseViewModel { if state == .available, isShowedAllowLargeDownloadAlert(blocks: blocks) { return } + + if state == .available { + analytics.bulkDownloadVideosSubsection( + courseID: courseStructure?.id ?? "", + sectionID: chapter.id, + subSectionID: sequential.id, + videos: blocks.count + ) + } else if state == .finished { + analytics.bulkDeleteVideosSubsection( + courseID: courseStructure?.id ?? "", + subSectionID: sequential.id, + videos: blocks.count + ) + } + await download(state: state, blocks: blocks) } @@ -292,6 +329,14 @@ public class CourseContainerViewModel: BaseCourseViewModel { blockName: vertical.displayName ) } + + func trackViewCertificateClicked(courseID: String) { + analytics.trackCourseEvent( + .courseViewCertificateClicked, + biValue: .courseViewCertificateClicked, + courseID: courseID + ) + } func trackSequentialClicked(_ sequential: CourseSequential) { guard let course = courseStructure else { return } @@ -303,9 +348,9 @@ public class CourseContainerViewModel: BaseCourseViewModel { ) } - func trackResumeCourseTapped(blockId: String) { + func trackResumeCourseClicked(blockId: String) { guard let course = courseStructure else { return } - analytics.resumeCourseTapped( + analytics.resumeCourseClicked( courseId: course.id, courseName: course.displayName, blockId: blockId diff --git a/Course/Course/Presentation/CourseAnalytics.swift b/Course/Course/Presentation/CourseAnalytics.swift index ca7e66db7..b17cda57c 100644 --- a/Course/Course/Presentation/CourseAnalytics.swift +++ b/Course/Course/Presentation/CourseAnalytics.swift @@ -6,10 +6,11 @@ // import Foundation +import Core //sourcery: AutoMockable public protocol CourseAnalytics { - func resumeCourseTapped(courseId: String, courseName: String, blockId: String) + func resumeCourseClicked(courseId: String, courseName: String, blockId: String) func sequentialClicked(courseId: String, courseName: String, blockId: String, blockName: String) func verticalClicked(courseId: String, courseName: String, blockId: String, blockName: String) func nextBlockClicked(courseId: String, courseName: String, blockId: String, blockName: String) @@ -22,11 +23,47 @@ public protocol CourseAnalytics { func courseOutlineDatesTabClicked(courseId: String, courseName: String) func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) func courseOutlineHandoutsTabClicked(courseId: String, courseName: String) + func datesComponentTapped( + courseId: String, + blockId: String, + link: String, + supported: Bool + ) + func trackCourseEvent(_ event: AnalyticsEvent, biValue: EventBIValue, courseID: String) + func plsEvent( + _ event: AnalyticsEvent, + bivalue: EventBIValue, + courseID: String, + screenName: String, + type: String + ) + + func plsSuccessEvent( + _ event: AnalyticsEvent, + bivalue: EventBIValue, + courseID: String, + screenName: String, + type: String, + success: Bool + ) + + func bulkDownloadVideosToggle(courseID: String, action: Bool) + func bulkDownloadVideosSubsection( + courseID: String, + sectionID: String, + subSectionID: String, + videos: Int + ) + func bulkDeleteVideosSubsection( + courseID: String, + subSectionID: String, + videos: Int + ) } #if DEBUG class CourseAnalyticsMock: CourseAnalytics { - public func resumeCourseTapped(courseId: String, courseName: String, blockId: String) {} + public func resumeCourseClicked(courseId: String, courseName: String, blockId: String) {} public func sequentialClicked(courseId: String, courseName: String, blockId: String, blockName: String) {} public func verticalClicked(courseId: String, courseName: String, blockId: String, blockName: String) {} public func nextBlockClicked(courseId: String, courseName: String, blockId: String, blockName: String) {} @@ -44,5 +81,41 @@ class CourseAnalyticsMock: CourseAnalytics { public func courseOutlineDatesTabClicked(courseId: String, courseName: String) {} public func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) {} public func courseOutlineHandoutsTabClicked(courseId: String, courseName: String) {} + public func datesComponentTapped( + courseId: String, + blockId: String, + link: String, + supported: Bool + ) {} + public func trackCourseEvent(_ event: AnalyticsEvent, biValue: EventBIValue, courseID: String) {} + public func plsEvent( + _ event: AnalyticsEvent, + bivalue: EventBIValue, + courseID: String, + screenName: String, + type: String + ) {} + + public func plsSuccessEvent( + _ event: AnalyticsEvent, + bivalue: EventBIValue, + courseID: String, + screenName: String, + type: String, + success: Bool + ) {} + public func bulkDownloadVideosToggle(courseID: String, action: Bool) {} + public func bulkDownloadVideosSubsection( + courseID: String, + sectionID: String, + subSectionID: String, + videos: Int + ) {} + + public func bulkDeleteVideosSubsection( + courseID: String, + subSectionID: String, + videos: Int + ) {} } #endif diff --git a/Course/Course/Presentation/Dates/CourseDatesView.swift b/Course/Course/Presentation/Dates/CourseDatesView.swift index b4589c92f..482cdacd4 100644 --- a/Course/Course/Presentation/Dates/CourseDatesView.swift +++ b/Course/Course/Presentation/Dates/CourseDatesView.swift @@ -110,7 +110,8 @@ struct CourseDateListView: View { DatesStatusInfoView( datesBannerInfo: courseDates.datesBannerInfo, courseID: courseID, - courseDatesViewModel: viewModel + courseDatesViewModel: viewModel, + screen: .courseDates ) .padding(.bottom, 16) } @@ -322,6 +323,9 @@ struct StyleBlock: View { Task { await viewModel.showCourseDetails(componentID: block.firstComponentBlockID) } + viewModel.logdateComponentTapped(block: block, supported: true) + } else { + viewModel.logdateComponentTapped(block: block, supported: false) } } } @@ -393,7 +397,8 @@ struct CourseDatesView_Previews: PreviewProvider { router: CourseRouterMock(), cssInjector: CSSInjectorMock(), connectivity: Connectivity(), - courseID: "") + courseID: "", + analytics: CourseAnalyticsMock()) CourseDatesView( courseID: "", diff --git a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift index a8d2f0f72..5aaee1da4 100644 --- a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift +++ b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift @@ -29,19 +29,22 @@ public class CourseDatesViewModel: ObservableObject { let router: CourseRouter let connectivity: ConnectivityProtocol let courseID: String + let analytics: CourseAnalytics public init( interactor: CourseInteractorProtocol, router: CourseRouter, cssInjector: CSSInjector, connectivity: ConnectivityProtocol, - courseID: String + courseID: String, + analytics: CourseAnalytics ) { self.interactor = interactor self.router = router self.cssInjector = cssInjector self.connectivity = connectivity self.courseID = courseID + self.analytics = analytics addObservers() } @@ -95,13 +98,29 @@ public class CourseDatesViewModel: ObservableObject { } @MainActor - func shiftDueDates(courseID: String, withProgress: Bool = true) async { + func shiftDueDates(courseID: String, withProgress: Bool = true, screen: DatesStatusInfoScreen, type: String) async { isShowProgress = withProgress do { try await interactor.shiftDueDates(courseID: courseID) NotificationCenter.default.post(name: .shiftCourseDates, object: courseID) isShowProgress = false + trackPLSuccessEvent( + .plsShiftDatesSuccess, + bivalue: .plsShiftDatesSuccess, + courseID: courseID, + screenName: screen.rawValue, + type: type, + success: true + ) } catch let error { + trackPLSuccessEvent( + .plsShiftDatesSuccess, + bivalue: .plsShiftDatesSuccess, + courseID: courseID, + screenName: screen.rawValue, + type: type, + success: false + ) isShowProgress = false if error.isInternetError || error is NoCachedDataError { errorMessage = CoreLocalization.Error.slowOrNoInternetConnection @@ -139,4 +158,47 @@ extension CourseDatesViewModel { func resetDueDatesShiftedFlag() { dueDatesShifted = false } + + func logdateComponentTapped(block: CourseDateBlock, supported: Bool) { + analytics.datesComponentTapped( + courseId: courseID, + blockId: block.firstComponentBlockID, + link: block.link, + supported: supported + ) + } + + func trackPLSEvent( + _ event: AnalyticsEvent, + bivalue: EventBIValue, + courseID: String, + screenName: String, + type: String + ) { + analytics.plsEvent( + event, + bivalue: bivalue, + courseID: courseID, + screenName: screenName, + type: type + ) + } + + private func trackPLSuccessEvent( + _ event: AnalyticsEvent, + bivalue: EventBIValue, + courseID: String, + screenName: String, + type: String, + success: Bool + ) { + analytics.plsSuccessEvent( + event, + bivalue: bivalue, + courseID: courseID, + screenName: screenName, + type: type, + success: success + ) + } } diff --git a/Course/Course/Presentation/Dates/DatesStatusInfoView.swift b/Course/Course/Presentation/Dates/DatesStatusInfoView.swift index 38a878dfe..b193dae22 100644 --- a/Course/Course/Presentation/Dates/DatesStatusInfoView.swift +++ b/Course/Course/Presentation/Dates/DatesStatusInfoView.swift @@ -10,11 +10,18 @@ import SwiftUI import Core import Theme +public enum DatesStatusInfoScreen: String { + case courseDashbaord = "course_dashbaord" + case courseDates = "course_dates" +} + struct DatesStatusInfoView: View { let datesBannerInfo: DatesBannerInfo let courseID: String var courseDatesViewModel: CourseDatesViewModel? var courseContainerViewModel: CourseContainerViewModel? + var screen: DatesStatusInfoScreen + @State private var isLoading = false var body: some View { @@ -40,11 +47,26 @@ struct DatesStatusInfoView: View { UnitButtonView(type: .custom(button)) { guard !isLoading else { return } isLoading = true + courseDatesViewModel?.trackPLSEvent( + .plsShiftDatesClicked, + bivalue: .plsShiftDatesClicked, + courseID: courseID, + screenName: screen.rawValue, + type: datesBannerInfo.status?.analyticsBannerType ?? "" + ) Task { if courseDatesViewModel != nil { - await courseDatesViewModel?.shiftDueDates(courseID: courseID) + await courseDatesViewModel?.shiftDueDates( + courseID: courseID, + screen: screen, + type: datesBannerInfo.status?.analyticsBannerType ?? "" + ) } else if courseContainerViewModel != nil { - await courseContainerViewModel?.shiftDueDates(courseID: courseID) + await courseContainerViewModel?.shiftDueDates( + courseID: courseID, + screen: screen, + type: datesBannerInfo.status?.analyticsBannerType ?? "" + ) } isLoading = false } @@ -59,6 +81,15 @@ struct DatesStatusInfoView: View { .stroke(Theme.Colors.datesSectionStroke, lineWidth: 2) ) .background(Theme.Colors.datesSectionBackground) + .onFirstAppear { + courseDatesViewModel?.trackPLSEvent( + .plsBannerViewed, + bivalue: .plsBannerViewed, + courseID: courseID, + screenName: screen.rawValue, + type: datesBannerInfo.status?.analyticsBannerType ?? "" + ) + } } } @@ -75,7 +106,8 @@ struct DatesStatusInfoView_Previews: PreviewProvider { DatesStatusInfoView( datesBannerInfo: datesBannerInfo, - courseID: "courseID" + courseID: "courseID", + screen: .courseDashbaord ) } } diff --git a/Course/Course/Presentation/Handouts/HandoutsView.swift b/Course/Course/Presentation/Handouts/HandoutsView.swift index 73a9af0a8..404b6d29a 100644 --- a/Course/Course/Presentation/Handouts/HandoutsView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsView.swift @@ -44,6 +44,11 @@ struct HandoutsView: View { announcements: nil, router: viewModel.router, cssInjector: viewModel.cssInjector) + viewModel.analytics.trackCourseEvent( + .courseHandouts, + biValue: .courseHandouts, + courseID: courseID + ) }) Divider() HandoutsItemCell(type: .announcements, onTapAction: { @@ -53,6 +58,11 @@ struct HandoutsView: View { announcements: viewModel.updates, router: viewModel.router, cssInjector: viewModel.cssInjector) + viewModel.analytics.trackCourseEvent( + .courseAnnouncement, + biValue: .courseAnnouncement, + courseID: courseID + ) } }) }.padding(.horizontal, 32) @@ -108,7 +118,8 @@ struct HandoutsView_Previews: PreviewProvider { router: CourseRouterMock(), cssInjector: CSSInjectorMock(), connectivity: Connectivity(), - courseID: "") + courseID: "", + analytics: CourseAnalyticsMock()) HandoutsView(courseID: "", viewModel: viewModel) } diff --git a/Course/Course/Presentation/Handouts/HandoutsViewModel.swift b/Course/Course/Presentation/Handouts/HandoutsViewModel.swift index 2055e4adb..c5fc64a64 100644 --- a/Course/Course/Presentation/Handouts/HandoutsViewModel.swift +++ b/Course/Course/Presentation/Handouts/HandoutsViewModel.swift @@ -28,18 +28,21 @@ public class HandoutsViewModel: ObservableObject { let cssInjector: CSSInjector let router: CourseRouter let connectivity: ConnectivityProtocol + let analytics: CourseAnalytics public init( interactor: CourseInteractorProtocol, router: CourseRouter, cssInjector: CSSInjector, connectivity: ConnectivityProtocol, - courseID: String + courseID: String, + analytics: CourseAnalytics ) { self.interactor = interactor self.router = router self.cssInjector = cssInjector self.connectivity = connectivity + self.analytics = analytics } @MainActor diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index b4ebb132e..b67b902eb 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -66,7 +66,8 @@ public struct CourseOutlineView: View { DatesStatusInfoView( datesBannerInfo: courseDeadlineInfo.datesBannerInfo, courseID: courseID, - courseContainerViewModel: viewModel + courseContainerViewModel: viewModel, + screen: .courseDashbaord ) .padding(.horizontal, 16) .padding(.top, 16) @@ -96,7 +97,7 @@ public struct CourseOutlineView: View { } } - viewModel.trackResumeCourseTapped( + viewModel.trackResumeCourseClicked( blockId: continueBlock?.id ?? "" ) @@ -208,7 +209,8 @@ public struct CourseOutlineView: View { viewModel.storage.userSettings.map { VideoDownloadQualityContainerView( downloadQuality: $0.downloadQuality, - didSelect: viewModel.update(downloadQuality:) + didSelect: viewModel.update(downloadQuality:), + analytics: viewModel.coreAnalytics ) } } @@ -247,7 +249,8 @@ public struct CourseOutlineView: View { }, onTap: { showingDownloads = true - } + }, + analytics: viewModel.analytics ) viewModel.userSettings.map { VideoDownloadQualityBarView( @@ -290,7 +293,10 @@ public struct CourseOutlineView: View { .multilineTextAlignment(.center) StyledButton( CourseLocalization.Outline.viewCertificate, - action: { openCertificateView = true }, + action: { + openCertificateView = true + viewModel.trackViewCertificateClicked(courseID: courseID) + }, isTransparent: true ) .frame(width: 141) @@ -335,7 +341,8 @@ struct CourseOutlineView_Previews: PreviewProvider { courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) Task { await withTaskGroup(of: Void.self) { group in diff --git a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarView.swift b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarView.swift index f99f17ca6..50ca98bbb 100644 --- a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarView.swift +++ b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarView.swift @@ -22,12 +22,14 @@ struct CourseVideoDownloadBarView: View { courseStructure: CourseStructure, courseViewModel: CourseContainerViewModel, onNotInternetAvaliable: (() -> Void)?, - onTap: (() -> Void)? = nil + onTap: (() -> Void)? = nil, + analytics: CourseAnalytics ) { self._viewModel = .init( wrappedValue: .init( courseStructure: courseStructure, - courseViewModel: courseViewModel + courseViewModel: courseViewModel, + analytics: analytics ) ) self.onNotInternetAvaliable = onNotInternetAvaliable diff --git a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift index 91b0cd281..463d8596e 100644 --- a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift +++ b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift @@ -15,6 +15,7 @@ final class CourseVideoDownloadBarViewModel: ObservableObject { private let courseStructure: CourseStructure private let courseViewModel: CourseContainerViewModel + private let analytics: CourseAnalytics @Published private(set) var currentDownloadTask: DownloadDataTask? @Published private(set) var isOn: Bool = false @@ -105,10 +106,12 @@ final class CourseVideoDownloadBarViewModel: ObservableObject { init( courseStructure: CourseStructure, - courseViewModel: CourseContainerViewModel + courseViewModel: CourseContainerViewModel, + analytics: CourseAnalytics ) { self.courseStructure = courseStructure self.courseViewModel = courseViewModel + self.analytics = analytics observers() } @@ -132,6 +135,7 @@ final class CourseVideoDownloadBarViewModel: ObservableObject { Task { await self.downloadAll(isOn: false) } + analytics.bulkDownloadVideosToggle(courseID: courseStructure.id, action: false) self.courseViewModel.router.dismiss(animated: true) }, type: .deleteVideo @@ -152,13 +156,15 @@ final class CourseVideoDownloadBarViewModel: ObservableObject { Task { await self.downloadAll(isOn: false) } + analytics.bulkDownloadVideosToggle(courseID: courseStructure.id, action: false) self.courseViewModel.router.dismiss(animated: true) }, type: .deleteVideo ) return } - + + analytics.bulkDownloadVideosToggle(courseID: courseStructure.id, action: true) await downloadAll(isOn: true) } diff --git a/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift b/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift index f81c4b91d..4418d3513 100644 --- a/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift +++ b/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift @@ -15,17 +15,20 @@ struct VideoDownloadQualityContainerView: View { private var downloadQuality: DownloadQuality private var didSelect: ((DownloadQuality) -> Void)? + private let analytics: CoreAnalytics - init(downloadQuality: DownloadQuality, didSelect: ((DownloadQuality) -> Void)?) { + init(downloadQuality: DownloadQuality, didSelect: ((DownloadQuality) -> Void)?, analytics: CoreAnalytics) { self.downloadQuality = downloadQuality self.didSelect = didSelect + self.analytics = analytics } var body: some View { NavigationView { VideoDownloadQualityView( downloadQuality: downloadQuality, - didSelect: didSelect + didSelect: didSelect, + analytics: analytics ) .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift index 6249ccb7b..485eb5c17 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift @@ -73,6 +73,7 @@ public struct EncodedVideoPlayer: View { if progress == 1 { viewModel.router.presentAppReview() } + }, seconds: { seconds in currentTime = seconds }) diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift index 6163c8f93..8d2defbcd 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift @@ -33,7 +33,7 @@ public class EncodedVideoPlayerViewModel: VideoPlayerViewModel { courseID: courseID, languages: languages, interactor: interactor, - router: router, + router: router, appStorage: appStorage, connectivity: connectivity) diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index 70218103b..bd3e8f4a8 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -1114,6 +1114,281 @@ open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { } } +// MARK: - CoreAnalytics + +open class CoreAnalyticsMock: CoreAnalytics, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func trackEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, [String: Any]?) -> Void + perform?(`event`, `parameters`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void + perform?(`event`, `biValue`, `parameters`) + } + + open func appreview(_ event: AnalyticsEvent, biValue: EventBIValue, action: String?, rating: Int?) { + addInvocation(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) + let perform = methodPerformValue(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) as? (AnalyticsEvent, EventBIValue, String?, Int?) -> Void + perform?(`event`, `biValue`, `action`, `rating`) + } + + open func videoQualityChanged(_ event: AnalyticsEvent, bivalue: EventBIValue, value: String, oldValue: String) { + addInvocation(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) + let perform = methodPerformValue(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) as? (AnalyticsEvent, EventBIValue, String, String) -> Void + perform?(`event`, `bivalue`, `value`, `oldValue`) + } + + open func trackEvent(_ event: AnalyticsEvent) { + addInvocation(.m_trackEvent__event(Parameter.value(`event`))) + let perform = methodPerformValue(.m_trackEvent__event(Parameter.value(`event`))) as? (AnalyticsEvent) -> Void + perform?(`event`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void + perform?(`event`, `biValue`) + } + + + fileprivate enum MethodType { + case m_trackEvent__eventparameters_parameters(Parameter, Parameter<[String: Any]?>) + case m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter, Parameter, Parameter<[String: Any]?>) + case m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter, Parameter, Parameter, Parameter) + case m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter, Parameter, Parameter, Parameter) + case m_trackEvent__event(Parameter) + case m_trackEvent__eventbiValue_biValue(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_trackEvent__eventparameters_parameters(let lhsEvent, let lhsParameters), .m_trackEvent__eventparameters_parameters(let rhsEvent, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValueparameters_parameters(let lhsEvent, let lhsBivalue, let lhsParameters), .m_trackEvent__eventbiValue_biValueparameters_parameters(let rhsEvent, let rhsBivalue, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_appreview__eventbiValue_biValueaction_actionrating_rating(let lhsEvent, let lhsBivalue, let lhsAction, let lhsRating), .m_appreview__eventbiValue_biValueaction_actionrating_rating(let rhsEvent, let rhsBivalue, let rhsAction, let rhsRating)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRating, rhs: rhsRating, with: matcher), lhsRating, rhsRating, "rating")) + return Matcher.ComparisonResult(results) + + case (.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let lhsEvent, let lhsBivalue, let lhsValue, let lhsOldvalue), .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let rhsEvent, let rhsBivalue, let rhsValue, let rhsOldvalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "bivalue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsValue, rhs: rhsValue, with: matcher), lhsValue, rhsValue, "value")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOldvalue, rhs: rhsOldvalue, with: matcher), lhsOldvalue, rhsOldvalue, "oldValue")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__event(let lhsEvent), .m_trackEvent__event(let rhsEvent)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_trackEvent__eventbiValue_biValue(let rhsEvent, let rhsBivalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_trackEvent__eventparameters_parameters(p0, p1): return p0.intValue + p1.intValue + case let .m_trackEvent__eventbiValue_biValueparameters_parameters(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_appreview__eventbiValue_biValueaction_actionrating_rating(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_trackEvent__event(p0): return p0.intValue + case let .m_trackEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_trackEvent__eventparameters_parameters: return ".trackEvent(_:parameters:)" + case .m_trackEvent__eventbiValue_biValueparameters_parameters: return ".trackEvent(_:biValue:parameters:)" + case .m_appreview__eventbiValue_biValueaction_actionrating_rating: return ".appreview(_:biValue:action:rating:)" + case .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue: return ".videoQualityChanged(_:bivalue:value:oldValue:)" + case .m_trackEvent__event: return ".trackEvent(_:)" + case .m_trackEvent__eventbiValue_biValue: return ".trackEvent(_:biValue:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`))} + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter) -> Verify { return Verify(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`))} + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter) -> Verify { return Verify(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`))} + public static func trackEvent(_ event: Parameter) -> Verify { return Verify(method: .m_trackEvent__event(`event`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`), performs: perform) + } + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String?, Int?) -> Void) -> Perform { + return Perform(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`), performs: perform) + } + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String, String) -> Void) -> Perform { + return Perform(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`), performs: perform) + } + public static func trackEvent(_ event: Parameter, perform: @escaping (AnalyticsEvent) -> Void) -> Perform { + return Perform(method: .m_trackEvent__event(`event`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + // MARK: - CourseAnalytics open class CourseAnalyticsMock: CourseAnalytics, Mock { @@ -1158,9 +1433,9 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { - open func resumeCourseTapped(courseId: String, courseName: String, blockId: String) { - addInvocation(.m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`blockId`))) - let perform = methodPerformValue(.m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`blockId`))) as? (String, String, String) -> Void + open func resumeCourseClicked(courseId: String, courseName: String, blockId: String) { + addInvocation(.m_resumeCourseClicked__courseId_courseIdcourseName_courseNameblockId_blockId(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_resumeCourseClicked__courseId_courseIdcourseName_courseNameblockId_blockId(Parameter.value(`courseId`), Parameter.value(`courseName`), Parameter.value(`blockId`))) as? (String, String, String) -> Void perform?(`courseId`, `courseName`, `blockId`) } @@ -1236,9 +1511,51 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { perform?(`courseId`, `courseName`) } + open func datesComponentTapped(courseId: String, blockId: String, link: String, supported: Bool) { + addInvocation(.m_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported(Parameter.value(`courseId`), Parameter.value(`blockId`), Parameter.value(`link`), Parameter.value(`supported`))) + let perform = methodPerformValue(.m_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported(Parameter.value(`courseId`), Parameter.value(`blockId`), Parameter.value(`link`), Parameter.value(`supported`))) as? (String, String, String, Bool) -> Void + perform?(`courseId`, `blockId`, `link`, `supported`) + } + + open func trackCourseEvent(_ event: AnalyticsEvent, biValue: EventBIValue, courseID: String) { + addInvocation(.m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`courseID`))) as? (AnalyticsEvent, EventBIValue, String) -> Void + perform?(`event`, `biValue`, `courseID`) + } + + open func plsEvent(_ event: AnalyticsEvent, bivalue: EventBIValue, courseID: String, screenName: String, type: String) { + addInvocation(.m_plsEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_type(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`courseID`), Parameter.value(`screenName`), Parameter.value(`type`))) + let perform = methodPerformValue(.m_plsEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_type(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`courseID`), Parameter.value(`screenName`), Parameter.value(`type`))) as? (AnalyticsEvent, EventBIValue, String, String, String) -> Void + perform?(`event`, `bivalue`, `courseID`, `screenName`, `type`) + } + + open func plsSuccessEvent(_ event: AnalyticsEvent, bivalue: EventBIValue, courseID: String, screenName: String, type: String, success: Bool) { + addInvocation(.m_plsSuccessEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_typesuccess_success(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`courseID`), Parameter.value(`screenName`), Parameter.value(`type`), Parameter.value(`success`))) + let perform = methodPerformValue(.m_plsSuccessEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_typesuccess_success(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`courseID`), Parameter.value(`screenName`), Parameter.value(`type`), Parameter.value(`success`))) as? (AnalyticsEvent, EventBIValue, String, String, String, Bool) -> Void + perform?(`event`, `bivalue`, `courseID`, `screenName`, `type`, `success`) + } + + open func bulkDownloadVideosToggle(courseID: String, action: Bool) { + addInvocation(.m_bulkDownloadVideosToggle__courseID_courseIDaction_action(Parameter.value(`courseID`), Parameter.value(`action`))) + let perform = methodPerformValue(.m_bulkDownloadVideosToggle__courseID_courseIDaction_action(Parameter.value(`courseID`), Parameter.value(`action`))) as? (String, Bool) -> Void + perform?(`courseID`, `action`) + } + + open func bulkDownloadVideosSubsection(courseID: String, sectionID: String, subSectionID: String, videos: Int) { + addInvocation(.m_bulkDownloadVideosSubsection__courseID_courseIDsectionID_sectionIDsubSectionID_subSectionIDvideos_videos(Parameter.value(`courseID`), Parameter.value(`sectionID`), Parameter.value(`subSectionID`), Parameter.value(`videos`))) + let perform = methodPerformValue(.m_bulkDownloadVideosSubsection__courseID_courseIDsectionID_sectionIDsubSectionID_subSectionIDvideos_videos(Parameter.value(`courseID`), Parameter.value(`sectionID`), Parameter.value(`subSectionID`), Parameter.value(`videos`))) as? (String, String, String, Int) -> Void + perform?(`courseID`, `sectionID`, `subSectionID`, `videos`) + } + + open func bulkDeleteVideosSubsection(courseID: String, subSectionID: String, videos: Int) { + addInvocation(.m_bulkDeleteVideosSubsection__courseID_courseIDsubSectionID_subSectionIDvideos_videos(Parameter.value(`courseID`), Parameter.value(`subSectionID`), Parameter.value(`videos`))) + let perform = methodPerformValue(.m_bulkDeleteVideosSubsection__courseID_courseIDsubSectionID_subSectionIDvideos_videos(Parameter.value(`courseID`), Parameter.value(`subSectionID`), Parameter.value(`videos`))) as? (String, String, Int) -> Void + perform?(`courseID`, `subSectionID`, `videos`) + } + fileprivate enum MethodType { - case m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(Parameter, Parameter, Parameter) + case m_resumeCourseClicked__courseId_courseIdcourseName_courseNameblockId_blockId(Parameter, Parameter, Parameter) case m_sequentialClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter, Parameter, Parameter, Parameter) case m_verticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter, Parameter, Parameter, Parameter) case m_nextBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(Parameter, Parameter, Parameter, Parameter) @@ -1251,10 +1568,17 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { case m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) + case m_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported(Parameter, Parameter, Parameter, Parameter) + case m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(Parameter, Parameter, Parameter) + case m_plsEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_type(Parameter, Parameter, Parameter, Parameter, Parameter) + case m_plsSuccessEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_typesuccess_success(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter) + case m_bulkDownloadVideosToggle__courseID_courseIDaction_action(Parameter, Parameter) + case m_bulkDownloadVideosSubsection__courseID_courseIDsectionID_sectionIDsubSectionID_subSectionIDvideos_videos(Parameter, Parameter, Parameter, Parameter) + case m_bulkDeleteVideosSubsection__courseID_courseIDsubSectionID_subSectionIDvideos_videos(Parameter, Parameter, Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { - case (.m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(let lhsCourseid, let lhsCoursename, let lhsBlockid), .m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(let rhsCourseid, let rhsCoursename, let rhsBlockid)): + case (.m_resumeCourseClicked__courseId_courseIdcourseName_courseNameblockId_blockId(let lhsCourseid, let lhsCoursename, let lhsBlockid), .m_resumeCourseClicked__courseId_courseIdcourseName_courseNameblockId_blockId(let rhsCourseid, let rhsCoursename, let rhsBlockid)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) @@ -1344,13 +1668,68 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { 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_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported(let lhsCourseid, let lhsBlockid, let lhsLink, let lhsSupported), .m_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported(let rhsCourseid, let rhsBlockid, let rhsLink, let rhsSupported)): + 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: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "blockId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsLink, rhs: rhsLink, with: matcher), lhsLink, rhsLink, "link")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSupported, rhs: rhsSupported, with: matcher), lhsSupported, rhsSupported, "supported")) + return Matcher.ComparisonResult(results) + + case (.m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(let lhsEvent, let lhsBivalue, let lhsCourseid), .m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(let rhsEvent, let rhsBivalue, let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + return Matcher.ComparisonResult(results) + + case (.m_plsEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_type(let lhsEvent, let lhsBivalue, let lhsCourseid, let lhsScreenname, let lhsType), .m_plsEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_type(let rhsEvent, let rhsBivalue, let rhsCourseid, let rhsScreenname, let rhsType)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "bivalue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsScreenname, rhs: rhsScreenname, with: matcher), lhsScreenname, rhsScreenname, "screenName")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsType, rhs: rhsType, with: matcher), lhsType, rhsType, "type")) + return Matcher.ComparisonResult(results) + + case (.m_plsSuccessEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_typesuccess_success(let lhsEvent, let lhsBivalue, let lhsCourseid, let lhsScreenname, let lhsType, let lhsSuccess), .m_plsSuccessEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_typesuccess_success(let rhsEvent, let rhsBivalue, let rhsCourseid, let rhsScreenname, let rhsType, let rhsSuccess)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "bivalue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsScreenname, rhs: rhsScreenname, with: matcher), lhsScreenname, rhsScreenname, "screenName")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsType, rhs: rhsType, with: matcher), lhsType, rhsType, "type")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSuccess, rhs: rhsSuccess, with: matcher), lhsSuccess, rhsSuccess, "success")) + return Matcher.ComparisonResult(results) + + case (.m_bulkDownloadVideosToggle__courseID_courseIDaction_action(let lhsCourseid, let lhsAction), .m_bulkDownloadVideosToggle__courseID_courseIDaction_action(let rhsCourseid, let rhsAction)): + 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: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + return Matcher.ComparisonResult(results) + + case (.m_bulkDownloadVideosSubsection__courseID_courseIDsectionID_sectionIDsubSectionID_subSectionIDvideos_videos(let lhsCourseid, let lhsSectionid, let lhsSubsectionid, let lhsVideos), .m_bulkDownloadVideosSubsection__courseID_courseIDsectionID_sectionIDsubSectionID_subSectionIDvideos_videos(let rhsCourseid, let rhsSectionid, let rhsSubsectionid, let rhsVideos)): + 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: lhsSectionid, rhs: rhsSectionid, with: matcher), lhsSectionid, rhsSectionid, "sectionID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSubsectionid, rhs: rhsSubsectionid, with: matcher), lhsSubsectionid, rhsSubsectionid, "subSectionID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsVideos, rhs: rhsVideos, with: matcher), lhsVideos, rhsVideos, "videos")) + return Matcher.ComparisonResult(results) + + case (.m_bulkDeleteVideosSubsection__courseID_courseIDsubSectionID_subSectionIDvideos_videos(let lhsCourseid, let lhsSubsectionid, let lhsVideos), .m_bulkDeleteVideosSubsection__courseID_courseIDsubSectionID_subSectionIDvideos_videos(let rhsCourseid, let rhsSubsectionid, let rhsVideos)): + 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: lhsSubsectionid, rhs: rhsSubsectionid, with: matcher), lhsSubsectionid, rhsSubsectionid, "subSectionID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsVideos, rhs: rhsVideos, with: matcher), lhsVideos, rhsVideos, "videos")) + return Matcher.ComparisonResult(results) default: return .none } } func intValue() -> Int { switch self { - case let .m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_resumeCourseClicked__courseId_courseIdcourseName_courseNameblockId_blockId(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case let .m_sequentialClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue case let .m_verticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue case let .m_nextBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue @@ -1363,11 +1742,18 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { 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 + case let .m_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_plsEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_type(p0, p1, p2, p3, p4): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + case let .m_plsSuccessEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_typesuccess_success(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + case let .m_bulkDownloadVideosToggle__courseID_courseIDaction_action(p0, p1): return p0.intValue + p1.intValue + case let .m_bulkDownloadVideosSubsection__courseID_courseIDsectionID_sectionIDsubSectionID_subSectionIDvideos_videos(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_bulkDeleteVideosSubsection__courseID_courseIDsubSectionID_subSectionIDvideos_videos(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue } } func assertionName() -> String { switch self { - case .m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId: return ".resumeCourseTapped(courseId:courseName:blockId:)" + case .m_resumeCourseClicked__courseId_courseIdcourseName_courseNameblockId_blockId: return ".resumeCourseClicked(courseId:courseName:blockId:)" case .m_sequentialClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName: return ".sequentialClicked(courseId:courseName:blockId:blockName:)" case .m_verticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName: return ".verticalClicked(courseId:courseName:blockId:blockName:)" case .m_nextBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName: return ".nextBlockClicked(courseId:courseName:blockId:blockName:)" @@ -1380,6 +1766,13 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { 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:)" + case .m_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported: return ".datesComponentTapped(courseId:blockId:link:supported:)" + case .m_trackCourseEvent__eventbiValue_biValuecourseID_courseID: return ".trackCourseEvent(_:biValue:courseID:)" + case .m_plsEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_type: return ".plsEvent(_:bivalue:courseID:screenName:type:)" + case .m_plsSuccessEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_typesuccess_success: return ".plsSuccessEvent(_:bivalue:courseID:screenName:type:success:)" + case .m_bulkDownloadVideosToggle__courseID_courseIDaction_action: return ".bulkDownloadVideosToggle(courseID:action:)" + case .m_bulkDownloadVideosSubsection__courseID_courseIDsectionID_sectionIDsubSectionID_subSectionIDvideos_videos: return ".bulkDownloadVideosSubsection(courseID:sectionID:subSectionID:videos:)" + case .m_bulkDeleteVideosSubsection__courseID_courseIDsubSectionID_subSectionIDvideos_videos: return ".bulkDeleteVideosSubsection(courseID:subSectionID:videos:)" } } } @@ -1398,7 +1791,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { public struct Verify { fileprivate var method: MethodType - public static func resumeCourseTapped(courseId: Parameter, courseName: Parameter, blockId: Parameter) -> Verify { return Verify(method: .m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(`courseId`, `courseName`, `blockId`))} + public static func resumeCourseClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter) -> Verify { return Verify(method: .m_resumeCourseClicked__courseId_courseIdcourseName_courseNameblockId_blockId(`courseId`, `courseName`, `blockId`))} public static func sequentialClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter, blockName: Parameter) -> Verify { return Verify(method: .m_sequentialClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(`courseId`, `courseName`, `blockId`, `blockName`))} public static func verticalClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter, blockName: Parameter) -> Verify { return Verify(method: .m_verticalClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(`courseId`, `courseName`, `blockId`, `blockName`))} public static func nextBlockClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter, blockName: Parameter) -> Verify { return Verify(method: .m_nextBlockClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(`courseId`, `courseName`, `blockId`, `blockName`))} @@ -1411,14 +1804,21 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { 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`))} + public static func datesComponentTapped(courseId: Parameter, blockId: Parameter, link: Parameter, supported: Parameter) -> Verify { return Verify(method: .m_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported(`courseId`, `blockId`, `link`, `supported`))} + public static func trackCourseEvent(_ event: Parameter, biValue: Parameter, courseID: Parameter) -> Verify { return Verify(method: .m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(`event`, `biValue`, `courseID`))} + public static func plsEvent(_ event: Parameter, bivalue: Parameter, courseID: Parameter, screenName: Parameter, type: Parameter) -> Verify { return Verify(method: .m_plsEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_type(`event`, `bivalue`, `courseID`, `screenName`, `type`))} + public static func plsSuccessEvent(_ event: Parameter, bivalue: Parameter, courseID: Parameter, screenName: Parameter, type: Parameter, success: Parameter) -> Verify { return Verify(method: .m_plsSuccessEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_typesuccess_success(`event`, `bivalue`, `courseID`, `screenName`, `type`, `success`))} + public static func bulkDownloadVideosToggle(courseID: Parameter, action: Parameter) -> Verify { return Verify(method: .m_bulkDownloadVideosToggle__courseID_courseIDaction_action(`courseID`, `action`))} + public static func bulkDownloadVideosSubsection(courseID: Parameter, sectionID: Parameter, subSectionID: Parameter, videos: Parameter) -> Verify { return Verify(method: .m_bulkDownloadVideosSubsection__courseID_courseIDsectionID_sectionIDsubSectionID_subSectionIDvideos_videos(`courseID`, `sectionID`, `subSectionID`, `videos`))} + public static func bulkDeleteVideosSubsection(courseID: Parameter, subSectionID: Parameter, videos: Parameter) -> Verify { return Verify(method: .m_bulkDeleteVideosSubsection__courseID_courseIDsubSectionID_subSectionIDvideos_videos(`courseID`, `subSectionID`, `videos`))} } public struct Perform { fileprivate var method: MethodType var performs: Any - public static func resumeCourseTapped(courseId: Parameter, courseName: Parameter, blockId: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { - return Perform(method: .m_resumeCourseTapped__courseId_courseIdcourseName_courseNameblockId_blockId(`courseId`, `courseName`, `blockId`), performs: perform) + public static func resumeCourseClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { + return Perform(method: .m_resumeCourseClicked__courseId_courseIdcourseName_courseNameblockId_blockId(`courseId`, `courseName`, `blockId`), performs: perform) } public static func sequentialClicked(courseId: Parameter, courseName: Parameter, blockId: Parameter, blockName: Parameter, perform: @escaping (String, String, String, String) -> Void) -> Perform { return Perform(method: .m_sequentialClicked__courseId_courseIdcourseName_courseNameblockId_blockIdblockName_blockName(`courseId`, `courseName`, `blockId`, `blockName`), performs: perform) @@ -1456,6 +1856,27 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { public static func courseOutlineHandoutsTabClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { return Perform(method: .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) } + public static func datesComponentTapped(courseId: Parameter, blockId: Parameter, link: Parameter, supported: Parameter, perform: @escaping (String, String, String, Bool) -> Void) -> Perform { + return Perform(method: .m_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported(`courseId`, `blockId`, `link`, `supported`), performs: perform) + } + public static func trackCourseEvent(_ event: Parameter, biValue: Parameter, courseID: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String) -> Void) -> Perform { + return Perform(method: .m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(`event`, `biValue`, `courseID`), performs: perform) + } + public static func plsEvent(_ event: Parameter, bivalue: Parameter, courseID: Parameter, screenName: Parameter, type: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String, String, String) -> Void) -> Perform { + return Perform(method: .m_plsEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_type(`event`, `bivalue`, `courseID`, `screenName`, `type`), performs: perform) + } + public static func plsSuccessEvent(_ event: Parameter, bivalue: Parameter, courseID: Parameter, screenName: Parameter, type: Parameter, success: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String, String, String, Bool) -> Void) -> Perform { + return Perform(method: .m_plsSuccessEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_typesuccess_success(`event`, `bivalue`, `courseID`, `screenName`, `type`, `success`), performs: perform) + } + public static func bulkDownloadVideosToggle(courseID: Parameter, action: Parameter, perform: @escaping (String, Bool) -> Void) -> Perform { + return Perform(method: .m_bulkDownloadVideosToggle__courseID_courseIDaction_action(`courseID`, `action`), performs: perform) + } + public static func bulkDownloadVideosSubsection(courseID: Parameter, sectionID: Parameter, subSectionID: Parameter, videos: Parameter, perform: @escaping (String, String, String, Int) -> Void) -> Perform { + return Perform(method: .m_bulkDownloadVideosSubsection__courseID_courseIDsectionID_sectionIDsubSectionID_subSectionIDvideos_videos(`courseID`, `sectionID`, `subSectionID`, `videos`), performs: perform) + } + public static func bulkDeleteVideosSubsection(courseID: Parameter, subSectionID: Parameter, videos: Parameter, perform: @escaping (String, String, Int) -> Void) -> Perform { + return Perform(method: .m_bulkDeleteVideosSubsection__courseID_courseIDsubSectionID_subSectionIDvideos_videos(`courseID`, `subSectionID`, `videos`), performs: perform) + } } public func given(_ method: Given) { diff --git a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift index 57948a995..d8160aff6 100644 --- a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift @@ -39,7 +39,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) let block = CourseBlock( @@ -143,7 +144,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) let courseStructure = CourseStructure( @@ -199,7 +201,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -241,7 +244,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) Given(interactor, .getCourseBlocks(courseID: "123", @@ -280,7 +284,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) Given(interactor, .getCourseBlocks(courseID: "123", @@ -319,7 +324,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) viewModel.trackSelectedTab(selection: .course, courseId: "1", courseName: "name") @@ -447,7 +453,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates() @@ -564,7 +571,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates() @@ -681,7 +689,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates() @@ -799,7 +808,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates() @@ -925,7 +935,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates() @@ -1051,7 +1062,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates() @@ -1196,7 +1208,8 @@ final class CourseContainerViewModelTests: XCTestCase { courseStart: Date(), courseEnd: nil, enrollmentStart: nil, - enrollmentEnd: nil + enrollmentEnd: nil, + coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates() diff --git a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift index 765b8a763..68f23f71b 100644 --- a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift @@ -38,7 +38,8 @@ final class CourseDateViewModelTests: XCTestCase { router: router, cssInjector: cssInjector, connectivity: connectivity, - courseID: "1") + courseID: "1", + analytics: CourseAnalyticsMock()) await viewModel.getCourseDates(courseID: "1") @@ -63,7 +64,8 @@ final class CourseDateViewModelTests: XCTestCase { router: router, cssInjector: cssInjector, connectivity: connectivity, - courseID: "1") + courseID: "1", + analytics: CourseAnalyticsMock()) await viewModel.getCourseDates(courseID: "1") @@ -88,7 +90,8 @@ final class CourseDateViewModelTests: XCTestCase { router: router, cssInjector: cssInjector, connectivity: connectivity, - courseID: "1") + courseID: "1", + analytics: CourseAnalyticsMock()) await viewModel.getCourseDates(courseID: "1") diff --git a/Course/CourseTests/Presentation/Unit/HandoutsViewModelTests.swift b/Course/CourseTests/Presentation/Unit/HandoutsViewModelTests.swift index 4e9500a7f..c5874f90c 100644 --- a/Course/CourseTests/Presentation/Unit/HandoutsViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/HandoutsViewModelTests.swift @@ -24,11 +24,13 @@ final class HandoutsViewModelTests: XCTestCase { Given(interactor, .getHandouts(courseID: .any, willReturn: "Result")) Given(interactor, .getUpdates(courseID: .any, willReturn: courseUpdate)) - let viewModel = HandoutsViewModel(interactor: interactor, - router: router, - cssInjector: CSSInjectorMock(), - connectivity: connectivity, - courseID: "123") + let viewModel = HandoutsViewModel( + interactor: interactor, + router: router, + cssInjector: CSSInjectorMock(), + connectivity: connectivity, + courseID: "123", + analytics: CourseAnalyticsMock()) await viewModel.getHandouts(courseID: "") @@ -50,11 +52,13 @@ final class HandoutsViewModelTests: XCTestCase { Given(interactor, .getHandouts(courseID: .any, willThrow: noInternetError)) Given(interactor, .getUpdates(courseID: .any, willThrow: noInternetError)) - let viewModel = HandoutsViewModel(interactor: interactor, - router: router, - cssInjector: CSSInjectorMock(), - connectivity: connectivity, - courseID: "123") + let viewModel = HandoutsViewModel( + interactor: interactor, + router: router, + cssInjector: CSSInjectorMock(), + connectivity: connectivity, + courseID: "123", + analytics: CourseAnalyticsMock()) await viewModel.getHandouts(courseID: "") @@ -74,11 +78,13 @@ final class HandoutsViewModelTests: XCTestCase { Given(interactor, .getHandouts(courseID: .any, willThrow: NSError())) Given(interactor, .getUpdates(courseID: .any, willThrow: NSError())) - let viewModel = HandoutsViewModel(interactor: interactor, - router: router, - cssInjector: CSSInjectorMock(), - connectivity: connectivity, - courseID: "123") + let viewModel = HandoutsViewModel( + interactor: interactor, + router: router, + cssInjector: CSSInjectorMock(), + connectivity: connectivity, + courseID: "123", + analytics: CourseAnalyticsMock()) await viewModel.getHandouts(courseID: "") @@ -99,11 +105,13 @@ final class HandoutsViewModelTests: XCTestCase { Given(interactor, .getUpdates(courseID: .any, willReturn: courseUpdate)) - let viewModel = HandoutsViewModel(interactor: interactor, - router: router, - cssInjector: CSSInjectorMock(), - connectivity: connectivity, - courseID: "123") + let viewModel = HandoutsViewModel( + interactor: interactor, + router: router, + cssInjector: CSSInjectorMock(), + connectivity: connectivity, + courseID: "123", + analytics: CourseAnalyticsMock()) await viewModel.getUpdates(courseID: "") @@ -120,11 +128,13 @@ final class HandoutsViewModelTests: XCTestCase { let router = CourseRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = HandoutsViewModel(interactor: interactor, - router: router, - cssInjector: CSSInjectorMock(), - connectivity: connectivity, - courseID: "123") + let viewModel = HandoutsViewModel( + interactor: interactor, + router: router, + cssInjector: CSSInjectorMock(), + connectivity: connectivity, + courseID: "123", + analytics: CourseAnalyticsMock()) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -145,11 +155,13 @@ final class HandoutsViewModelTests: XCTestCase { let router = CourseRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = HandoutsViewModel(interactor: interactor, - router: router, - cssInjector: CSSInjectorMock(), - connectivity: connectivity, - courseID: "123") + let viewModel = HandoutsViewModel( + interactor: interactor, + router: router, + cssInjector: CSSInjectorMock(), + connectivity: connectivity, + courseID: "123", + analytics: CourseAnalyticsMock()) Given(interactor, .getUpdates(courseID: .any, willThrow: NSError())) diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index 9e3f24919..8f1b9f79e 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -1114,6 +1114,281 @@ open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { } } +// MARK: - CoreAnalytics + +open class CoreAnalyticsMock: CoreAnalytics, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func trackEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, [String: Any]?) -> Void + perform?(`event`, `parameters`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void + perform?(`event`, `biValue`, `parameters`) + } + + open func appreview(_ event: AnalyticsEvent, biValue: EventBIValue, action: String?, rating: Int?) { + addInvocation(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) + let perform = methodPerformValue(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) as? (AnalyticsEvent, EventBIValue, String?, Int?) -> Void + perform?(`event`, `biValue`, `action`, `rating`) + } + + open func videoQualityChanged(_ event: AnalyticsEvent, bivalue: EventBIValue, value: String, oldValue: String) { + addInvocation(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) + let perform = methodPerformValue(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) as? (AnalyticsEvent, EventBIValue, String, String) -> Void + perform?(`event`, `bivalue`, `value`, `oldValue`) + } + + open func trackEvent(_ event: AnalyticsEvent) { + addInvocation(.m_trackEvent__event(Parameter.value(`event`))) + let perform = methodPerformValue(.m_trackEvent__event(Parameter.value(`event`))) as? (AnalyticsEvent) -> Void + perform?(`event`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void + perform?(`event`, `biValue`) + } + + + fileprivate enum MethodType { + case m_trackEvent__eventparameters_parameters(Parameter, Parameter<[String: Any]?>) + case m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter, Parameter, Parameter<[String: Any]?>) + case m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter, Parameter, Parameter, Parameter) + case m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter, Parameter, Parameter, Parameter) + case m_trackEvent__event(Parameter) + case m_trackEvent__eventbiValue_biValue(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_trackEvent__eventparameters_parameters(let lhsEvent, let lhsParameters), .m_trackEvent__eventparameters_parameters(let rhsEvent, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValueparameters_parameters(let lhsEvent, let lhsBivalue, let lhsParameters), .m_trackEvent__eventbiValue_biValueparameters_parameters(let rhsEvent, let rhsBivalue, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_appreview__eventbiValue_biValueaction_actionrating_rating(let lhsEvent, let lhsBivalue, let lhsAction, let lhsRating), .m_appreview__eventbiValue_biValueaction_actionrating_rating(let rhsEvent, let rhsBivalue, let rhsAction, let rhsRating)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRating, rhs: rhsRating, with: matcher), lhsRating, rhsRating, "rating")) + return Matcher.ComparisonResult(results) + + case (.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let lhsEvent, let lhsBivalue, let lhsValue, let lhsOldvalue), .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let rhsEvent, let rhsBivalue, let rhsValue, let rhsOldvalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "bivalue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsValue, rhs: rhsValue, with: matcher), lhsValue, rhsValue, "value")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOldvalue, rhs: rhsOldvalue, with: matcher), lhsOldvalue, rhsOldvalue, "oldValue")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__event(let lhsEvent), .m_trackEvent__event(let rhsEvent)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_trackEvent__eventbiValue_biValue(let rhsEvent, let rhsBivalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_trackEvent__eventparameters_parameters(p0, p1): return p0.intValue + p1.intValue + case let .m_trackEvent__eventbiValue_biValueparameters_parameters(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_appreview__eventbiValue_biValueaction_actionrating_rating(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_trackEvent__event(p0): return p0.intValue + case let .m_trackEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_trackEvent__eventparameters_parameters: return ".trackEvent(_:parameters:)" + case .m_trackEvent__eventbiValue_biValueparameters_parameters: return ".trackEvent(_:biValue:parameters:)" + case .m_appreview__eventbiValue_biValueaction_actionrating_rating: return ".appreview(_:biValue:action:rating:)" + case .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue: return ".videoQualityChanged(_:bivalue:value:oldValue:)" + case .m_trackEvent__event: return ".trackEvent(_:)" + case .m_trackEvent__eventbiValue_biValue: return ".trackEvent(_:biValue:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`))} + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter) -> Verify { return Verify(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`))} + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter) -> Verify { return Verify(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`))} + public static func trackEvent(_ event: Parameter) -> Verify { return Verify(method: .m_trackEvent__event(`event`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`), performs: perform) + } + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String?, Int?) -> Void) -> Perform { + return Perform(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`), performs: perform) + } + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String, String) -> Void) -> Perform { + return Perform(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`), performs: perform) + } + public static func trackEvent(_ event: Parameter, perform: @escaping (AnalyticsEvent) -> Void) -> Perform { + return Perform(method: .m_trackEvent__event(`event`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + // MARK: - DashboardAnalytics open class DashboardAnalyticsMock: DashboardAnalytics, Mock { diff --git a/Discovery/Discovery/Presentation/DiscoveryAnalytics.swift b/Discovery/Discovery/Presentation/DiscoveryAnalytics.swift index bcb8190fc..bfc9e7075 100644 --- a/Discovery/Discovery/Presentation/DiscoveryAnalytics.swift +++ b/Discovery/Discovery/Presentation/DiscoveryAnalytics.swift @@ -6,6 +6,7 @@ // import Foundation +import Core //sourcery: AutoMockable public protocol DiscoveryAnalytics { @@ -15,6 +16,9 @@ public protocol DiscoveryAnalytics { func viewCourseClicked(courseId: String, courseName: String) func courseEnrollClicked(courseId: String, courseName: String) func courseEnrollSuccess(courseId: String, courseName: String) + func externalLinkOpen(url: String, screen: String) + func externalLinkOpenAction(url: String, screen: String, action: String) + func discoveryEvent(event: AnalyticsEvent, biValue: EventBIValue) } #if DEBUG @@ -25,5 +29,8 @@ class DiscoveryAnalyticsMock: DiscoveryAnalytics { public func viewCourseClicked(courseId: String, courseName: String) {} public func courseEnrollClicked(courseId: String, courseName: String) {} public func courseEnrollSuccess(courseId: String, courseName: String) {} + public func externalLinkOpen(url: String, screen: String) {} + public func externalLinkOpenAction(url: String, screen: String, action: String) {} + public func discoveryEvent(event: AnalyticsEvent, biValue: EventBIValue) {} } #endif diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift index 99cd937b0..18e91733b 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift @@ -135,14 +135,25 @@ extension DiscoveryWebviewViewModel: WebViewNavigationDelegate { } if let url = request.url, outsideLink || capturedLink || externalLink, UIApplication.shared.canOpenURL(url) { + analytics.externalLinkOpen(url: url.absoluteString, screen: sourceScreen.value ?? "") router.presentAlert( alertTitle: DiscoveryLocalization.Alert.leavingAppTitle, alertMessage: DiscoveryLocalization.Alert.leavingAppMessage, positiveAction: CoreLocalization.Webview.Alert.continue, onCloseTapped: { [weak self] in self?.router.dismiss(animated: true) - }, okTapped: { + self?.analytics.externalLinkOpenAction( + url: url.absoluteString, + screen: self?.sourceScreen.value ?? "", + action: "cancel" + ) + }, okTapped: { [weak self] in UIApplication.shared.open(url, options: [:]) + self?.analytics.externalLinkOpenAction( + url: url.absoluteString, + screen: self?.sourceScreen.value ?? "", + action: "continue" + ) }, type: .default(positiveAction: CoreLocalization.Webview.Alert.continue, image: nil) ) return true @@ -176,6 +187,7 @@ extension DiscoveryWebviewViewModel: WebViewNavigationDelegate { case .programDetail: guard let pathID = programDetailPathId(from: url) else { return false } + analytics.discoveryEvent(event: .discoveryProgramInfo, biValue: .discoveryProgramInfo) router.showWebDiscoveryDetails( pathID: pathID, discoveryType: .programDetail(pathID), diff --git a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index 7c7b67d29..2721eded5 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -1114,6 +1114,281 @@ open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { } } +// MARK: - CoreAnalytics + +open class CoreAnalyticsMock: CoreAnalytics, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func trackEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, [String: Any]?) -> Void + perform?(`event`, `parameters`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void + perform?(`event`, `biValue`, `parameters`) + } + + open func appreview(_ event: AnalyticsEvent, biValue: EventBIValue, action: String?, rating: Int?) { + addInvocation(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) + let perform = methodPerformValue(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) as? (AnalyticsEvent, EventBIValue, String?, Int?) -> Void + perform?(`event`, `biValue`, `action`, `rating`) + } + + open func videoQualityChanged(_ event: AnalyticsEvent, bivalue: EventBIValue, value: String, oldValue: String) { + addInvocation(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) + let perform = methodPerformValue(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) as? (AnalyticsEvent, EventBIValue, String, String) -> Void + perform?(`event`, `bivalue`, `value`, `oldValue`) + } + + open func trackEvent(_ event: AnalyticsEvent) { + addInvocation(.m_trackEvent__event(Parameter.value(`event`))) + let perform = methodPerformValue(.m_trackEvent__event(Parameter.value(`event`))) as? (AnalyticsEvent) -> Void + perform?(`event`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void + perform?(`event`, `biValue`) + } + + + fileprivate enum MethodType { + case m_trackEvent__eventparameters_parameters(Parameter, Parameter<[String: Any]?>) + case m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter, Parameter, Parameter<[String: Any]?>) + case m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter, Parameter, Parameter, Parameter) + case m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter, Parameter, Parameter, Parameter) + case m_trackEvent__event(Parameter) + case m_trackEvent__eventbiValue_biValue(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_trackEvent__eventparameters_parameters(let lhsEvent, let lhsParameters), .m_trackEvent__eventparameters_parameters(let rhsEvent, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValueparameters_parameters(let lhsEvent, let lhsBivalue, let lhsParameters), .m_trackEvent__eventbiValue_biValueparameters_parameters(let rhsEvent, let rhsBivalue, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_appreview__eventbiValue_biValueaction_actionrating_rating(let lhsEvent, let lhsBivalue, let lhsAction, let lhsRating), .m_appreview__eventbiValue_biValueaction_actionrating_rating(let rhsEvent, let rhsBivalue, let rhsAction, let rhsRating)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRating, rhs: rhsRating, with: matcher), lhsRating, rhsRating, "rating")) + return Matcher.ComparisonResult(results) + + case (.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let lhsEvent, let lhsBivalue, let lhsValue, let lhsOldvalue), .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let rhsEvent, let rhsBivalue, let rhsValue, let rhsOldvalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "bivalue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsValue, rhs: rhsValue, with: matcher), lhsValue, rhsValue, "value")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOldvalue, rhs: rhsOldvalue, with: matcher), lhsOldvalue, rhsOldvalue, "oldValue")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__event(let lhsEvent), .m_trackEvent__event(let rhsEvent)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_trackEvent__eventbiValue_biValue(let rhsEvent, let rhsBivalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_trackEvent__eventparameters_parameters(p0, p1): return p0.intValue + p1.intValue + case let .m_trackEvent__eventbiValue_biValueparameters_parameters(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_appreview__eventbiValue_biValueaction_actionrating_rating(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_trackEvent__event(p0): return p0.intValue + case let .m_trackEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_trackEvent__eventparameters_parameters: return ".trackEvent(_:parameters:)" + case .m_trackEvent__eventbiValue_biValueparameters_parameters: return ".trackEvent(_:biValue:parameters:)" + case .m_appreview__eventbiValue_biValueaction_actionrating_rating: return ".appreview(_:biValue:action:rating:)" + case .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue: return ".videoQualityChanged(_:bivalue:value:oldValue:)" + case .m_trackEvent__event: return ".trackEvent(_:)" + case .m_trackEvent__eventbiValue_biValue: return ".trackEvent(_:biValue:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`))} + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter) -> Verify { return Verify(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`))} + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter) -> Verify { return Verify(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`))} + public static func trackEvent(_ event: Parameter) -> Verify { return Verify(method: .m_trackEvent__event(`event`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`), performs: perform) + } + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String?, Int?) -> Void) -> Perform { + return Perform(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`), performs: perform) + } + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String, String) -> Void) -> Perform { + return Perform(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`), performs: perform) + } + public static func trackEvent(_ event: Parameter, perform: @escaping (AnalyticsEvent) -> Void) -> Perform { + return Perform(method: .m_trackEvent__event(`event`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + // MARK: - DiscoveryAnalytics open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { @@ -1194,6 +1469,24 @@ open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { perform?(`courseId`, `courseName`) } + open func externalLinkOpen(url: String, screen: String) { + addInvocation(.m_externalLinkOpen__url_urlscreen_screen(Parameter.value(`url`), Parameter.value(`screen`))) + let perform = methodPerformValue(.m_externalLinkOpen__url_urlscreen_screen(Parameter.value(`url`), Parameter.value(`screen`))) as? (String, String) -> Void + perform?(`url`, `screen`) + } + + open func externalLinkOpenAction(url: String, screen: String, action: String) { + addInvocation(.m_externalLinkOpenAction__url_urlscreen_screenaction_action(Parameter.value(`url`), Parameter.value(`screen`), Parameter.value(`action`))) + let perform = methodPerformValue(.m_externalLinkOpenAction__url_urlscreen_screenaction_action(Parameter.value(`url`), Parameter.value(`screen`), Parameter.value(`action`))) as? (String, String, String) -> Void + perform?(`url`, `screen`, `action`) + } + + open func discoveryEvent(event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_discoveryEvent__event_eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_discoveryEvent__event_eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void + perform?(`event`, `biValue`) + } + fileprivate enum MethodType { case m_discoverySearchBarClicked @@ -1202,6 +1495,9 @@ open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { case m_viewCourseClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseEnrollClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseEnrollSuccess__courseId_courseIdcourseName_courseName(Parameter, Parameter) + case m_externalLinkOpen__url_urlscreen_screen(Parameter, Parameter) + case m_externalLinkOpenAction__url_urlscreen_screenaction_action(Parameter, Parameter, Parameter) + case m_discoveryEvent__event_eventbiValue_biValue(Parameter, Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { @@ -1236,6 +1532,25 @@ open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { 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_externalLinkOpen__url_urlscreen_screen(let lhsUrl, let lhsScreen), .m_externalLinkOpen__url_urlscreen_screen(let rhsUrl, let rhsScreen)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUrl, rhs: rhsUrl, with: matcher), lhsUrl, rhsUrl, "url")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsScreen, rhs: rhsScreen, with: matcher), lhsScreen, rhsScreen, "screen")) + return Matcher.ComparisonResult(results) + + case (.m_externalLinkOpenAction__url_urlscreen_screenaction_action(let lhsUrl, let lhsScreen, let lhsAction), .m_externalLinkOpenAction__url_urlscreen_screenaction_action(let rhsUrl, let rhsScreen, let rhsAction)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUrl, rhs: rhsUrl, with: matcher), lhsUrl, rhsUrl, "url")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsScreen, rhs: rhsScreen, with: matcher), lhsScreen, rhsScreen, "screen")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + return Matcher.ComparisonResult(results) + + case (.m_discoveryEvent__event_eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_discoveryEvent__event_eventbiValue_biValue(let rhsEvent, let rhsBivalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + return Matcher.ComparisonResult(results) default: return .none } } @@ -1248,6 +1563,9 @@ open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { case let .m_viewCourseClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseEnrollClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseEnrollSuccess__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue + case let .m_externalLinkOpen__url_urlscreen_screen(p0, p1): return p0.intValue + p1.intValue + case let .m_externalLinkOpenAction__url_urlscreen_screenaction_action(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_discoveryEvent__event_eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue } } func assertionName() -> String { @@ -1258,6 +1576,9 @@ open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { case .m_viewCourseClicked__courseId_courseIdcourseName_courseName: return ".viewCourseClicked(courseId:courseName:)" case .m_courseEnrollClicked__courseId_courseIdcourseName_courseName: return ".courseEnrollClicked(courseId:courseName:)" case .m_courseEnrollSuccess__courseId_courseIdcourseName_courseName: return ".courseEnrollSuccess(courseId:courseName:)" + case .m_externalLinkOpen__url_urlscreen_screen: return ".externalLinkOpen(url:screen:)" + case .m_externalLinkOpenAction__url_urlscreen_screenaction_action: return ".externalLinkOpenAction(url:screen:action:)" + case .m_discoveryEvent__event_eventbiValue_biValue: return ".discoveryEvent(event:biValue:)" } } } @@ -1282,6 +1603,9 @@ open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { public static func viewCourseClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_viewCourseClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseEnrollClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseEnrollClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseEnrollSuccess(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseEnrollSuccess__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} + public static func externalLinkOpen(url: Parameter, screen: Parameter) -> Verify { return Verify(method: .m_externalLinkOpen__url_urlscreen_screen(`url`, `screen`))} + public static func externalLinkOpenAction(url: Parameter, screen: Parameter, action: Parameter) -> Verify { return Verify(method: .m_externalLinkOpenAction__url_urlscreen_screenaction_action(`url`, `screen`, `action`))} + public static func discoveryEvent(event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_discoveryEvent__event_eventbiValue_biValue(`event`, `biValue`))} } public struct Perform { @@ -1306,6 +1630,15 @@ open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { public static func courseEnrollSuccess(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { return Perform(method: .m_courseEnrollSuccess__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) } + public static func externalLinkOpen(url: Parameter, screen: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_externalLinkOpen__url_urlscreen_screen(`url`, `screen`), performs: perform) + } + public static func externalLinkOpenAction(url: Parameter, screen: Parameter, action: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { + return Perform(method: .m_externalLinkOpenAction__url_urlscreen_screenaction_action(`url`, `screen`, `action`), performs: perform) + } + public static func discoveryEvent(event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_discoveryEvent__event_eventbiValue_biValue(`event`, `biValue`), performs: perform) + } } public func given(_ method: Given) { diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index 2202fd248..4da34aadc 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -1114,6 +1114,281 @@ open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { } } +// MARK: - CoreAnalytics + +open class CoreAnalyticsMock: CoreAnalytics, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func trackEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, [String: Any]?) -> Void + perform?(`event`, `parameters`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void + perform?(`event`, `biValue`, `parameters`) + } + + open func appreview(_ event: AnalyticsEvent, biValue: EventBIValue, action: String?, rating: Int?) { + addInvocation(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) + let perform = methodPerformValue(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) as? (AnalyticsEvent, EventBIValue, String?, Int?) -> Void + perform?(`event`, `biValue`, `action`, `rating`) + } + + open func videoQualityChanged(_ event: AnalyticsEvent, bivalue: EventBIValue, value: String, oldValue: String) { + addInvocation(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) + let perform = methodPerformValue(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) as? (AnalyticsEvent, EventBIValue, String, String) -> Void + perform?(`event`, `bivalue`, `value`, `oldValue`) + } + + open func trackEvent(_ event: AnalyticsEvent) { + addInvocation(.m_trackEvent__event(Parameter.value(`event`))) + let perform = methodPerformValue(.m_trackEvent__event(Parameter.value(`event`))) as? (AnalyticsEvent) -> Void + perform?(`event`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void + perform?(`event`, `biValue`) + } + + + fileprivate enum MethodType { + case m_trackEvent__eventparameters_parameters(Parameter, Parameter<[String: Any]?>) + case m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter, Parameter, Parameter<[String: Any]?>) + case m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter, Parameter, Parameter, Parameter) + case m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter, Parameter, Parameter, Parameter) + case m_trackEvent__event(Parameter) + case m_trackEvent__eventbiValue_biValue(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_trackEvent__eventparameters_parameters(let lhsEvent, let lhsParameters), .m_trackEvent__eventparameters_parameters(let rhsEvent, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValueparameters_parameters(let lhsEvent, let lhsBivalue, let lhsParameters), .m_trackEvent__eventbiValue_biValueparameters_parameters(let rhsEvent, let rhsBivalue, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_appreview__eventbiValue_biValueaction_actionrating_rating(let lhsEvent, let lhsBivalue, let lhsAction, let lhsRating), .m_appreview__eventbiValue_biValueaction_actionrating_rating(let rhsEvent, let rhsBivalue, let rhsAction, let rhsRating)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRating, rhs: rhsRating, with: matcher), lhsRating, rhsRating, "rating")) + return Matcher.ComparisonResult(results) + + case (.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let lhsEvent, let lhsBivalue, let lhsValue, let lhsOldvalue), .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let rhsEvent, let rhsBivalue, let rhsValue, let rhsOldvalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "bivalue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsValue, rhs: rhsValue, with: matcher), lhsValue, rhsValue, "value")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOldvalue, rhs: rhsOldvalue, with: matcher), lhsOldvalue, rhsOldvalue, "oldValue")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__event(let lhsEvent), .m_trackEvent__event(let rhsEvent)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_trackEvent__eventbiValue_biValue(let rhsEvent, let rhsBivalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_trackEvent__eventparameters_parameters(p0, p1): return p0.intValue + p1.intValue + case let .m_trackEvent__eventbiValue_biValueparameters_parameters(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_appreview__eventbiValue_biValueaction_actionrating_rating(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_trackEvent__event(p0): return p0.intValue + case let .m_trackEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_trackEvent__eventparameters_parameters: return ".trackEvent(_:parameters:)" + case .m_trackEvent__eventbiValue_biValueparameters_parameters: return ".trackEvent(_:biValue:parameters:)" + case .m_appreview__eventbiValue_biValueaction_actionrating_rating: return ".appreview(_:biValue:action:rating:)" + case .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue: return ".videoQualityChanged(_:bivalue:value:oldValue:)" + case .m_trackEvent__event: return ".trackEvent(_:)" + case .m_trackEvent__eventbiValue_biValue: return ".trackEvent(_:biValue:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`))} + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter) -> Verify { return Verify(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`))} + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter) -> Verify { return Verify(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`))} + public static func trackEvent(_ event: Parameter) -> Verify { return Verify(method: .m_trackEvent__event(`event`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`), performs: perform) + } + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String?, Int?) -> Void) -> Perform { + return Perform(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`), performs: perform) + } + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String, String) -> Void) -> Perform { + return Perform(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`), performs: perform) + } + public static func trackEvent(_ event: Parameter, perform: @escaping (AnalyticsEvent) -> Void) -> Perform { + return Perform(method: .m_trackEvent__event(`event`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + // MARK: - DiscussionAnalytics open class DiscussionAnalyticsMock: DiscussionAnalytics, Mock { diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 8077b4d8d..84bb16054 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -69,6 +69,14 @@ class AppAssembly: Assembly { r.resolve(AnalyticsManager.self)! }.inObjectScope(.container) + container.register(CoreAnalytics.self) { r in + r.resolve(AnalyticsManager.self)! + }.inObjectScope(.container) + + container.register(WhatsNewAnalytics.self) { r in + r.resolve(AnalyticsManager.self)! + }.inObjectScope(.container) + container.register(ConnectivityProtocol.self) { _ in Connectivity() } diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 74074812d..09d7b9c32 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -45,7 +45,8 @@ class ScreenAssembly: Assembly { // MARK: Startup screen container.register(StartupViewModel.self) { r in StartupViewModel( - router: r.resolve(AuthorizationRouter.self)! + router: r.resolve(AuthorizationRouter.self)!, + analytics: r.resolve(CoreAnalytics.self)! ) } @@ -207,7 +208,8 @@ class ScreenAssembly: Assembly { container.register(SettingsViewModel.self) { r in SettingsViewModel( interactor: r.resolve(ProfileInteractorProtocol.self)!, - router: r.resolve(ProfileRouter.self)! + router: r.resolve(ProfileRouter.self)!, + analytics: r.resolve(CoreAnalytics.self)! ) } @@ -215,7 +217,8 @@ class ScreenAssembly: Assembly { DeleteAccountViewModel( interactor: r.resolve(ProfileInteractorProtocol.self)!, router: r.resolve(ProfileRouter.self)!, - connectivity: r.resolve(ConnectivityProtocol.self)! + connectivity: r.resolve(ConnectivityProtocol.self)!, + analytics: r.resolve(ProfileAnalytics.self)! ) } @@ -266,7 +269,8 @@ class ScreenAssembly: Assembly { courseStart: courseStart, courseEnd: courseEnd, enrollmentStart: enrollmentStart, - enrollmentEnd: enrollmentEnd + enrollmentEnd: enrollmentEnd, + coreAnalytics: r.resolve(CoreAnalytics.self)! ) } @@ -346,7 +350,8 @@ class ScreenAssembly: Assembly { router: r.resolve(CourseRouter.self)!, cssInjector: r.resolve(CSSInjector.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, - courseID: courseID + courseID: courseID, + analytics: r.resolve(CourseAnalytics.self)! ) } @@ -356,7 +361,9 @@ class ScreenAssembly: Assembly { router: r.resolve(CourseRouter.self)!, cssInjector: r.resolve(CSSInjector.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, - courseID: courseID) + courseID: courseID, + analytics: r.resolve(CourseAnalytics.self)! + ) } // MARK: Discussion diff --git a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift index 4cb616fc8..116acfef8 100644 --- a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift +++ b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift @@ -13,11 +13,12 @@ import Dashboard import Profile import Course import Discussion +import WhatsNew import Swinject protocol AnalyticsService { func identify(id: String, username: String?, email: String?) - func logEvent(_ event: Event, parameters: [String: Any]?) + func logEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) } class AnalyticsManager: AuthorizationAnalytics, @@ -26,8 +27,9 @@ class AnalyticsManager: AuthorizationAnalytics, DashboardAnalytics, ProfileAnalytics, CourseAnalytics, - DiscussionAnalytics { - + DiscussionAnalytics, + CoreAnalytics, + WhatsNewAnalytics { private var services: [AnalyticsService] = [] // Init Analytics Manager @@ -57,64 +59,113 @@ class AnalyticsManager: AuthorizationAnalytics, } } + private func logEvent(_ event: AnalyticsEvent, parameters: [String: Any]? = nil) { + for service in services { + service.logEvent(event, parameters: parameters) + } + } + + // MARK: Generic event tracker functions + public func trackEvent(_ event: AnalyticsEvent, parameters: [String: Any]? = nil) { + logEvent(event, parameters: parameters) + } + + public func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { + var eventParams: [String: Any] = [EventParamKey.name: biValue.rawValue] + + if let parameters { + eventParams.merge(parameters, uniquingKeysWith: { (first, _) in first }) + } + + logEvent(event, parameters: eventParams) + } + + private func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + logEvent(event, parameters: [EventParamKey.name: biValue.rawValue]) + } + + // MARK: Pre Login + public func userLogin(method: AuthMethod) { - logEvent(.userLogin, parameters: [Key.method: method.analyticsValue]) + logEvent(.userLogin, parameters: [EventParamKey.method: method.analyticsValue]) + } + + public func registerClicked() { + trackEvent(.registerClicked, biValue: .registerClicked) + } + + public func signInClicked() { + trackEvent(.signInClicked, biValue: .signInClicked) } - public func signUpClicked() { - logEvent(.signUpClicked) + public func userSignInClicked() { + trackEvent(.userSignInClicked, biValue: .userSignInClicked) } public func createAccountClicked() { - logEvent(.createAccountClicked) + trackEvent(.createAccountClicked, biValue: .createAccountClicked) } - public func registrationSuccess() { - logEvent(.registrationSuccess) + public func registrationSuccess(method: String) { + let parameters = [ + EventParamKey.method: method, + EventParamKey.name: EventBIValue.registrationSuccess.rawValue + ] + logEvent(.registrationSuccess, parameters: parameters) } public func forgotPasswordClicked() { - logEvent(.forgotPasswordClicked) + trackEvent(.forgotPasswordClicked, biValue: .forgotPasswordClicked) } - public func resetPasswordClicked(success: Bool) { - logEvent(.resetPasswordClicked, parameters: [Key.success: success]) + public func resetPasswordClicked() { + trackEvent(.resetPasswordClicked, biValue: .resetPasswordClicked) + } + + public func resetPassword(success: Bool) { + trackEvent( + .resetPasswordSuccess, + biValue: .resetPasswordSuccess, + parameters: [EventParamKey.success: success] + ) } // MARK: MainScreenAnalytics public func mainDiscoveryTabClicked() { - logEvent(.mainDiscoveryTabClicked) + trackEvent(.mainDiscoveryTabClicked, biValue: .mainDiscoveryTabClicked) } public func mainDashboardTabClicked() { - logEvent(.mainDashboardTabClicked) + trackEvent(.mainDashboardTabClicked, biValue: .mainDashboardTabClicked) } public func mainProgramsTabClicked() { - logEvent(.mainProgramsTabClicked) + trackEvent(.mainProgramsTabClicked, biValue: .mainProgramsTabClicked) } public func mainProfileTabClicked() { - logEvent(.mainProfileTabClicked) + trackEvent(.mainProfileTabClicked, biValue: .mainProfileTabClicked) } // MARK: Discovery public func discoverySearchBarClicked() { - logEvent(.discoverySearchBarClicked) + trackEvent(.discoverySearchBarClicked, biValue: .discoverySearchBarClicked) } public func discoveryCoursesSearch(label: String, coursesCount: Int) { - logEvent(.discoveryCoursesSearch, - parameters: [Key.label: label, - Key.coursesCount: coursesCount]) + let parameters: [String: Any] = [EventParamKey.label: label, + EventParamKey.coursesCount: coursesCount, + EventParamKey.name: EventBIValue.discoveryCoursesSearch.rawValue] + logEvent(.discoveryCoursesSearch, parameters: parameters) } public func discoveryCourseClicked(courseID: String, courseName: String) { let parameters = [ - Key.courseID: courseID, - Key.courseName: courseName + EventParamKey.courseID: courseID, + EventParamKey.courseName: courseName, + EventParamKey.name: EventBIValue.discoveryCourseClicked.rawValue ] logEvent(.discoveryCourseClicked, parameters: parameters) } @@ -123,8 +174,9 @@ class AnalyticsManager: AuthorizationAnalytics, public func dashboardCourseClicked(courseID: String, courseName: String) { let parameters = [ - Key.courseID: courseID, - Key.courseName: courseName + EventParamKey.courseID: courseID, + EventParamKey.courseName: courseName, + EventParamKey.name: EventBIValue.dashboardCourseClicked.rawValue ] logEvent(.dashboardCourseClicked, parameters: parameters) } @@ -132,118 +184,277 @@ class AnalyticsManager: AuthorizationAnalytics, // MARK: Profile public func profileEditClicked() { - logEvent(.profileEditClicked) + let parameters = [ + EventParamKey.name: EventBIValue.profileEditClicked.rawValue, + EventParamKey.category: EventCategory.profile + ] + + logEvent(.profileEditClicked, parameters: parameters) + } + + public func profileSwitch(action: String) { + let parameters = [ + EventParamKey.action: action, + EventParamKey.category: EventCategory.profile + ] + + trackEvent(.profileWifiToggle, biValue: .profileWifiToggle, parameters: parameters) + } + + public func profileWifiToggle(action: String) { + let parameters = [ + EventParamKey.action: action, + EventParamKey.category: EventCategory.profile + ] + + trackEvent(.profileSwitch, biValue: .profileSwitch, parameters: parameters) } public func profileEditDoneClicked() { - logEvent(.profileEditDoneClicked) + let parameters = [ + EventParamKey.name: EventBIValue.profileEditDoneClicked.rawValue, + EventParamKey.category: EventCategory.profile + ] + logEvent(.profileEditDoneClicked, parameters: parameters) } public func profileDeleteAccountClicked() { + let parameters = [ + EventParamKey.name: EventBIValue.profileDeleteAccountClicked.rawValue, + EventParamKey.category: EventCategory.profile + ] logEvent(.profileDeleteAccountClicked) } public func profileVideoSettingsClicked() { - logEvent(.profileVideoSettingsClicked) + let parameters = [ + EventParamKey.name: EventBIValue.profileVideoSettingsClicked.rawValue, + EventParamKey.category: EventCategory.profile + ] + logEvent(.profileVideoSettingsClicked, parameters: parameters) + } + + public func profileUserDeleteAccountClicked() { + trackEvent( + .profileUserDeleteAccountClicked, + biValue: .profileUserDeleteAccountClicked, + parameters: [EventParamKey.category: EventCategory.profile] + ) + } + + public func profileDeleteAccountSuccess(success: Bool) { + trackEvent( + .profileUserDeleteAccountClicked, + biValue: .profileUserDeleteAccountClicked, + parameters: [ + EventParamKey.category: EventCategory.profile, + EventParamKey.success: success + ] + ) + } + + public func videoQualityChanged( + _ event: AnalyticsEvent, + bivalue: EventBIValue, + value: String, + oldValue: String + ) { + let parameters = [ + EventParamKey.name: bivalue.rawValue, + EventParamKey.category: EventCategory.video, + EventParamKey.value: value, + EventParamKey.oldValue: oldValue + ] + + logEvent(event, parameters: parameters) + } + + public func profileEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + let parameters = [ + EventParamKey.category: EventCategory.profile, + EventParamKey.name: biValue.rawValue + ] + + logEvent(event, parameters: parameters) } public func privacyPolicyClicked() { - logEvent(.privacyPolicyClicked) + let parameters = [ + EventParamKey.name: EventBIValue.privacyPolicyClicked.rawValue, + EventParamKey.category: EventCategory.profile + ] + logEvent(.privacyPolicyClicked, parameters: parameters) } public func cookiePolicyClicked() { + let parameters = [ + EventParamKey.name: EventBIValue.cookiePolicyClicked.rawValue, + EventParamKey.category: EventCategory.profile + ] logEvent(.cookiePolicyClicked) } public func emailSupportClicked() { - logEvent(.emailSupportClicked) + let parameters = [ + EventParamKey.name: EventBIValue.emailSupportClicked.rawValue, + EventParamKey.category: EventCategory.profile + ] + logEvent(.emailSupportClicked, parameters: parameters) + } + + public func faqClicked() { + let parameters = [ + EventParamKey.name: EventBIValue.faqClicked.rawValue, + EventParamKey.category: EventCategory.profile + ] + logEvent(.faqClicked, parameters: parameters) + } + + public func tosClicked() { + let parameters = [ + EventParamKey.name: EventBIValue.tosClicked.rawValue, + EventParamKey.category: EventCategory.profile + ] + logEvent(.tosClicked, parameters: parameters) + } + + public func dataSellClicked() { + let parameters = [ + EventParamKey.name: EventBIValue.dataSellClicked.rawValue, + EventParamKey.category: EventCategory.profile + ] + logEvent(.dataSellClicked, parameters: parameters) } public func userLogout(force: Bool) { - logEvent(.userLogout, parameters: [Key.force: force]) + let parameters = [ + EventParamKey.name: EventBIValue.userLogout.rawValue, + EventParamKey.category: EventCategory.profile + ] + logEvent(.userLogout, parameters: [EventParamKey.force: force]) } // MARK: Course public func courseEnrollClicked(courseId: String, courseName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.conversion: courseId, + EventParamKey.category: EventCategory.discovery ] logEvent(.courseEnrollClicked, parameters: parameters) } public func courseEnrollSuccess(courseId: String, courseName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.conversion: courseId, + EventParamKey.category: EventCategory.discovery ] logEvent(.courseEnrollSuccess, parameters: parameters) } + func externalLinkOpen(url: String, screen: String) { + let parameters = [ + EventParamKey.url: url, + EventParamKey.screenName: screen, + EventParamKey.category: EventCategory.discovery, + EventParamKey.name: EventBIValue.externalLinkOpenAlert.rawValue + ] + logEvent(.externalLinkOpenAlert, parameters: parameters) + } + + func externalLinkOpenAction(url: String, screen: String, action: String) { + let parameters = [ + EventParamKey.url: url, + EventParamKey.screenName: screen, + EventParamKey.alertAction: action, + EventParamKey.category: EventCategory.discovery, + EventParamKey.name: EventBIValue.externalLinkOpenAlertAction.rawValue + ] + logEvent(.externalLinkOpenAlertAction, parameters: parameters) + } + + public func discoveryEvent(event: AnalyticsEvent, biValue: EventBIValue) { + let parameters = [ + EventParamKey.category: EventCategory.discovery, + EventParamKey.name: biValue.rawValue + ] + + logEvent(event, parameters: parameters) + } + public func viewCourseClicked(courseId: String, courseName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.category: EventCategory.discovery ] logEvent(.viewCourseClicked, parameters: parameters) } - public func resumeCourseTapped(courseId: String, courseName: String, blockId: String) { + public func resumeCourseClicked(courseId: String, courseName: String, blockId: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName, - Key.blockID: blockId + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.blockID: blockId, + EventParamKey.name: EventBIValue.resumeCourseClicked.rawValue ] - logEvent(.resumeCourseTapped, parameters: parameters) + logEvent(.resumeCourseClicked, parameters: parameters) } public func sequentialClicked(courseId: String, courseName: String, blockId: String, blockName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName, - Key.blockID: blockId, - Key.blockName: blockName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.blockID: blockId, + EventParamKey.blockName: blockName, + EventParamKey.name: EventBIValue.sequentialClicked.rawValue ] logEvent(.sequentialClicked, parameters: parameters) } public func verticalClicked(courseId: String, courseName: String, blockId: String, blockName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName, - Key.blockID: blockId, - Key.blockName: blockName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.unitID: blockId, + EventParamKey.unitName: blockName ] logEvent(.verticalClicked, parameters: parameters) } public func nextBlockClicked(courseId: String, courseName: String, blockId: String, blockName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName, - Key.blockID: blockId, - Key.blockName: blockName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.blockID: blockId, + EventParamKey.blockName: blockName, + EventParamKey.name: EventBIValue.nextBlockClicked.rawValue ] logEvent(.nextBlockClicked, parameters: parameters) } public func prevBlockClicked(courseId: String, courseName: String, blockId: String, blockName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName, - Key.blockID: blockId, - Key.blockName: blockName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.blockID: blockId, + EventParamKey.blockName: blockName, + EventParamKey.name: EventBIValue.prevBlockClicked.rawValue ] logEvent(.prevBlockClicked, parameters: parameters) } public func finishVerticalClicked(courseId: String, courseName: String, blockId: String, blockName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName, - Key.blockID: blockId, - Key.blockName: blockName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.blockID: blockId, + EventParamKey.blockName: blockName, + EventParamKey.name: EventBIValue.finishVerticalClicked.rawValue ] logEvent(.finishVerticalClicked, parameters: parameters) } @@ -255,156 +466,260 @@ class AnalyticsManager: AuthorizationAnalytics, blockName: String ) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName, - Key.blockID: blockId, - Key.blockName: blockName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.blockID: blockId, + EventParamKey.blockName: blockName, + EventParamKey.name: EventBIValue.finishVerticalNextSectionClicked.rawValue ] logEvent(.finishVerticalNextSectionClicked, parameters: parameters) } public func finishVerticalBackToOutlineClicked(courseId: String, courseName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.name: EventBIValue.finishVerticalBackToOutlineClicked.rawValue ] logEvent(.finishVerticalBackToOutlineClicked, parameters: parameters) } public func courseOutlineCourseTabClicked(courseId: String, courseName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.name: EventBIValue.courseOutlineCourseTabClicked.rawValue ] logEvent(.courseOutlineCourseTabClicked, parameters: parameters) } public func courseOutlineVideosTabClicked(courseId: String, courseName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.name: EventBIValue.courseOutlineVideosTabClicked.rawValue ] logEvent(.courseOutlineVideosTabClicked, parameters: parameters) } public func courseOutlineDatesTabClicked(courseId: String, courseName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.name: EventBIValue.courseOutlineDatesTabClicked.rawValue ] logEvent(.courseOutlineDatesTabClicked, parameters: parameters) } public func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.name: EventBIValue.courseOutlineDiscussionTabClicked.rawValue ] logEvent(.courseOutlineDiscussionTabClicked, parameters: parameters) } public func courseOutlineHandoutsTabClicked(courseId: String, courseName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.name: EventBIValue.courseOutlineHandoutsTabClicked.rawValue ] logEvent(.courseOutlineHandoutsTabClicked, parameters: parameters) } + func datesComponentTapped( + courseId: String, + blockId: String, + link: String, + supported: Bool + ) { + let parameters: [String: Any] = [ + EventParamKey.courseID: courseId, + EventParamKey.blockID: blockId, + EventParamKey.link: link, + EventParamKey.supported: supported, + EventParamKey.category: EventCategory.courseDates, + EventParamKey.name: EventBIValue.datesComponentClicked.rawValue + ] + + logEvent(.datesComponentClicked, parameters: parameters) + } + + public func trackCourseEvent(_ event: AnalyticsEvent, biValue: EventBIValue, courseID: String) { + let parameters = [ + EventParamKey.courseID: courseID, + EventParamKey.category: EventCategory.course, + EventParamKey.name: biValue.rawValue + ] + + logEvent(event, parameters: parameters) + } + + public func plsEvent( + _ event: AnalyticsEvent, + bivalue: EventBIValue, + courseID: String, + screenName: String, + type: String + ) { + let parameters = [ + EventParamKey.courseID: courseID, + EventParamKey.name: bivalue.rawValue, + EventParamKey.screenName: screenName, + EventParamKey.bannerType: type + ] + + logEvent(event, parameters: parameters) + } + + public func plsSuccessEvent( + _ event: AnalyticsEvent, + bivalue: EventBIValue, + courseID: String, + screenName: String, + type: String, + success: Bool + ) { + let parameters: [String: Any] = [ + EventParamKey.courseID: courseID, + EventParamKey.name: bivalue.rawValue, + EventParamKey.screenName: screenName, + EventParamKey.bannerType: type, + EventParamKey.success: success + ] + + logEvent(event, parameters: parameters) + } + + public func bulkDownloadVideosToggle(courseID: String, action: Bool) { + let parameters: [String: Any] = [ + EventParamKey.courseID: courseID, + EventParamKey.action: action, + EventParamKey.category: EventCategory.video, + EventParamKey.name: EventBIValue.bulkDownloadVideosToggle.rawValue + ] + + logEvent(.bulkDownloadVideosToggle, parameters: parameters) + } + + public func bulkDownloadVideosSubsection( + courseID: String, + sectionID: String, + subSectionID: String, + videos: Int + ) { + let parameters: [String: Any] = [ + EventParamKey.courseID: courseID, + EventParamKey.courseSection: sectionID, + EventParamKey.courseSubsection: subSectionID, + EventParamKey.noOfVideos: videos, + EventParamKey.category: EventCategory.video, + EventParamKey.name: EventBIValue.bulkDownloadVideosSubsection.rawValue + ] + + logEvent(.bulkDownloadVideosSubsection, parameters: parameters) + } + + public func bulkDeleteVideosSubsection( + courseID: String, + subSectionID: String, + videos: Int + ) { + let parameters: [String: Any] = [ + EventParamKey.courseID: courseID, + EventParamKey.courseSubsection: subSectionID, + EventParamKey.noOfVideos: videos, + EventParamKey.category: EventCategory.video, + EventParamKey.name: EventBIValue.bulkDeleteVideosSubsection.rawValue + ] + + logEvent(.bulkDeleteVideosSubsection, parameters: parameters) + } + // MARK: Discussion public func discussionAllPostsClicked(courseId: String, courseName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.name: EventBIValue.discussionAllPostsClicked.rawValue ] logEvent(.discussionAllPostsClicked, parameters: parameters) } public func discussionFollowingClicked(courseId: String, courseName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.name: EventBIValue.discussionFollowingClicked.rawValue ] logEvent(.discussionFollowingClicked, parameters: parameters) } public func discussionTopicClicked(courseId: String, courseName: String, topicId: String, topicName: String) { let parameters = [ - Key.courseID: courseId, - Key.courseName: courseName, - Key.topicID: topicId, - Key.topicName: topicName + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.topicID: topicId, + EventParamKey.topicName: topicName, + EventParamKey.name: EventBIValue.discussionTopicClicked.rawValue ] logEvent(.discussionTopicClicked, parameters: parameters) } - private func logEvent(_ event: Event, parameters: [String: Any]? = nil) { - for service in services { - service.logEvent(event, parameters: parameters) + // MARK: app review + + public func appreview( + _ event: AnalyticsEvent, + biValue: EventBIValue, + action: String? = nil, + rating: Int? = 0 + ) { + var parameters: [String: Any] = [ + EventParamKey.category: EventCategory.appreviews, + EventParamKey.name: biValue.rawValue, + ] + + if rating != 0 { + parameters[EventParamKey.rating] = rating ?? 0 + } + + if let action { + parameters[EventParamKey.action] = action } + + logEvent(event, parameters: parameters) + } + + // MARK: whats new + + func whatsnewPopup() { + let parameters = [ + EventParamKey.name: EventBIValue.whatnewPopup.rawValue, + EventParamKey.category: EventCategory.whatsNew + ] + logEvent(.whatnewPopup, parameters: parameters) + } + + func whatsnewDone(totalScreens: Int) { + let parameters: [String: Any] = [ + EventParamKey.category: EventCategory.whatsNew, + EventParamKey.name: EventBIValue.whatnewDone.rawValue, + "total_screens": totalScreens + ] + + logEvent(.whatnewDone, parameters: parameters) + } + + func whatsnewClose(totalScreens: Int, currentScreen: Int) { + let parameters: [String: Any] = [ + EventParamKey.category: EventCategory.whatsNew, + EventParamKey.name: EventBIValue.whatnewClose.rawValue, + "total_screens": totalScreens, + "currently_viewed": currentScreen + ] + + logEvent(.whatnewClose, parameters: parameters) } -} - -struct Key { - static let courseID = "course_id" - static let courseName = "course_name" - static let topicID = "topic_id" - static let topicName = "topic_name" - static let blockID = "block_id" - static let blockName = "block_name" - static let method = "method" - static let label = "label" - static let coursesCount = "courses_count" - static let force = "force" - static let success = "success" -} - -enum Event: String { - case userLogin = "User_Login" - case signUpClicked = "Sign_up_Clicked" - case createAccountClicked = "Create_Account_Clicked" - case registrationSuccess = "Registration_Success" - case userLogout = "User_Logout" - case forgotPasswordClicked = "Forgot_password_Clicked" - case resetPasswordClicked = "Reset_password_Clicked" - - case mainDiscoveryTabClicked = "Main_Discovery_tab_Clicked" - case mainDashboardTabClicked = "Main_Dashboard_tab_Clicked" - case mainProgramsTabClicked = "Main_Programs_tab_Clicked" - case mainProfileTabClicked = "Main_Profile_tab_Clicked" - - case discoverySearchBarClicked = "Discovery_Search_Bar_Clicked" - case discoveryCoursesSearch = "Discovery_Courses_Search" - case discoveryCourseClicked = "Discovery_Course_Clicked" - - case dashboardCourseClicked = "Dashboard_Course_Clicked" - - case profileEditClicked = "Profile_Edit_Clicked" - case profileEditDoneClicked = "Profile_Edit_Done_Clicked" - case profileDeleteAccountClicked = "Profile_Delete_Account_Clicked" - case profileVideoSettingsClicked = "Profile_Video_settings_Clicked" - case privacyPolicyClicked = "Privacy_Policy_Clicked" - case cookiePolicyClicked = "Cookie_Policy_Clicked" - case emailSupportClicked = "Email_Support_Clicked" - - case courseEnrollClicked = "Course_Enroll_Clicked" - case courseEnrollSuccess = "Course_Enroll_Success" - case viewCourseClicked = "View_Course_Clicked" - case resumeCourseTapped = "Resume_Course_Tapped" - case sequentialClicked = "Sequential_Clicked" - case verticalClicked = "Vertical_Clicked" - case nextBlockClicked = "Next_Block_Clicked" - case prevBlockClicked = "Prev_Block_Clicked" - case finishVerticalClicked = "Finish_Vertical_Clicked" - case finishVerticalNextSectionClicked = "Finish_Vertical_Next_section_Clicked" - case finishVerticalBackToOutlineClicked = "Finish_Vertical_Back_to_outline_Clicked" - case courseOutlineCourseTabClicked = "Course_Outline_Course_tab_Clicked" - case courseOutlineVideosTabClicked = "Course_Outline_Videos_tab_Clicked" - case courseOutlineDatesTabClicked = "Course_Outline_Dates_tab_Clicked" - case courseOutlineDiscussionTabClicked = "Course_Outline_Discussion_tab_Clicked" - case courseOutlineHandoutsTabClicked = "Course_Outline_Handouts_tab_Clicked" - - case discussionAllPostsClicked = "Discussion_All_Posts_Clicked" - case discussionFollowingClicked = "Discussion_Following_Clicked" - case discussionTopicClicked = "Discussion_Topic_Clicked" } diff --git a/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift b/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift index f41df2555..12bd225e8 100644 --- a/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift +++ b/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift @@ -9,6 +9,9 @@ import Foundation import Firebase import Core +private let MaxParameterValueCharacters = 100 +private let MaxNameValueCharacters = 40 + class FirebaseAnalyticsService: AnalyticsService { // Init manager public init(config: ConfigProtocol) { @@ -21,8 +24,73 @@ class FirebaseAnalyticsService: AnalyticsService { Analytics.setUserID(id) } - func logEvent(_ event: Event, parameters: [String: Any]?) { - Analytics.logEvent(event.rawValue, parameters: parameters) + func logEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + guard let name = try? formatFirebaseName(event.rawValue) else { + debugLog("Firebase: event name is not supported: \(event.rawValue)") + return + } + + Analytics.logEvent(name, parameters: formatParamaters(params: parameters)) + } +} + +extension FirebaseAnalyticsService { + private func formatParamaters(params: [String: Any]?) -> [String: Any] { + // Firebase only supports String or Number as value for event parameters + var formattedParams: [String: Any] = [:] + + for (key, value) in params ?? [:] { + if let key = try? formatFirebaseName(key) { + formattedParams[key] = formatParamValue(value: value) + } + } + + return formattedParams + } + + private func formatFirebaseName(_ eventName: String) throws -> String { + let trimmed = eventName.trimmingCharacters(in: .whitespaces) + do { + let regex = try NSRegularExpression(pattern: "([^a-zA-Z0-9_])", options: .caseInsensitive) + let formattedString = regex.stringByReplacingMatches( + in: trimmed, + options: .reportProgress, + range: NSRange(location: 0, length: trimmed.count), + withTemplate: "_" + ) + + // Resize the string to maximum 40 characters if needed + let range = NSRange(location: 0, length: min(formattedString.count, MaxNameValueCharacters)) + var formattedName = NSString(string: formattedString).substring(with: range) + + while formattedName.contains("__") { + formattedName = formattedName.replace(string: "__", replacement: "_") + } + + return formattedName + + } catch { + debugLog("Could not parse event name for Firebase.") + throw(error) + } + } + + private func formatParamValue(value: Any?) -> Any? { + + guard var formattedValue = value as? String else { return value} + + // Firebase only supports 100 characters for parameter value + if formattedValue.count > MaxParameterValueCharacters { + let index = formattedValue.index(formattedValue.startIndex, offsetBy: MaxParameterValueCharacters) + formattedValue = String(formattedValue[.. String { + return replacingOccurrences(of: string, with: replacement, options: NSString.CompareOptions.literal, range: nil) + } } diff --git a/OpenEdX/Managers/SegmentAnalyticsService/SegmentAnalyticsService.swift b/OpenEdX/Managers/SegmentAnalyticsService/SegmentAnalyticsService.swift index 0d2315ec5..b8c2cd422 100644 --- a/OpenEdX/Managers/SegmentAnalyticsService/SegmentAnalyticsService.swift +++ b/OpenEdX/Managers/SegmentAnalyticsService/SegmentAnalyticsService.swift @@ -35,7 +35,7 @@ class SegmentAnalyticsService: AnalyticsService { analytics?.identify(userId: id, traits: traits) } - func logEvent(_ event: Event, parameters: [String: Any]?) { + func logEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { analytics?.track( name: event.rawValue, properties: parameters diff --git a/OpenEdX/RouteController.swift b/OpenEdX/RouteController.swift index 499992615..ac53c3d36 100644 --- a/OpenEdX/RouteController.swift +++ b/OpenEdX/RouteController.swift @@ -26,6 +26,10 @@ class RouteController: UIViewController { diContainer.resolve(AuthorizationAnalytics.self)! }() + private lazy var coreAnalytics: CoreAnalytics = { + diContainer.resolve(CoreAnalytics.self)! + }() + override func viewDidLoad() { super.viewDidLoad() @@ -39,6 +43,8 @@ class RouteController: UIViewController { self.showStartupScreen() } } + + coreAnalytics.trackEvent(.launch, biValue: .launch) } private func showStartupScreen() { @@ -62,10 +68,15 @@ class RouteController: UIViewController { } private func showMainOrWhatsNewScreen() { - var storage = Container.shared.resolve(WhatsNewStorage.self)! - let config = Container.shared.resolve(ConfigProtocol.self)! + guard var storage = Container.shared.resolve(WhatsNewStorage.self), + let config = Container.shared.resolve(ConfigProtocol.self), + let analytics = Container.shared.resolve(WhatsNewAnalytics.self) + else { + assert(false, "unable to resolve basic dependencies to start app") + return + } - let viewModel = WhatsNewViewModel(storage: storage) + let viewModel = WhatsNewViewModel(storage: storage, analytics: analytics) let shouldShowWhatsNew = viewModel.shouldShowWhatsNew() if shouldShowWhatsNew && config.features.whatNewEnabled { diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 8e9667458..b15cccd80 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -69,12 +69,13 @@ public class Router: AuthorizationRouter, let config = Container.shared.resolve(ConfigProtocol.self)! let persistence = Container.shared.resolve(CorePersistenceProtocol.self)! let coreStorage = Container.shared.resolve(CoreStorage.self)! + let analytics = Container.shared.resolve(WhatsNewAnalytics.self)! if let userId = coreStorage.user?.id { persistence.set(userId: userId) } - let viewModel = WhatsNewViewModel(storage: whatsNewStorage, sourceScreen: sourceScreen) + let viewModel = WhatsNewViewModel(storage: whatsNewStorage, sourceScreen: sourceScreen, analytics: analytics) let whatsNew = WhatsNewView(router: Container.shared.resolve(WhatsNewRouter.self)!, viewModel: viewModel) let shouldShowWhatsNew = viewModel.shouldShowWhatsNew() @@ -101,11 +102,15 @@ public class Router: AuthorizationRouter, guard let viewModel = Container.shared.resolve( SignInViewModel.self, argument: sourceScreen + ), let authAnalytics = Container.shared.resolve( + AuthorizationAnalytics.self ) else { return } let view = SignInView(viewModel: viewModel) let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) + + authAnalytics.signInClicked() } public func showStartupScreen() { @@ -129,10 +134,11 @@ public class Router: AuthorizationRouter, guard let config = Container.shared.resolve(ConfigProtocol.self), let storage = Container.shared.resolve(CoreStorage.self), let connectivity = Container.shared.resolve(ConnectivityProtocol.self), + let analytics = Container.shared.resolve(CoreAnalytics.self), connectivity.isInternetAvaliable else { return } - let vm = AppReviewViewModel(config: config, storage: storage) - if vm.shouldShowRatingView() { + let vm = AppReviewViewModel(config: config, storage: storage, analytics: analytics) + if true { presentView( transitionStyle: .crossDissolve, view: AppReviewView(viewModel: vm) @@ -211,7 +217,7 @@ public class Router: AuthorizationRouter, let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) - authAnalytics.signUpClicked() + authAnalytics.registerClicked() } public func showForgotPasswordScreen() { @@ -604,11 +610,13 @@ public class Router: AuthorizationRouter, public func showVideoDownloadQualityView( downloadQuality: DownloadQuality, - didSelect: ((DownloadQuality) -> Void)? + didSelect: ((DownloadQuality) -> Void)?, + analytics: CoreAnalytics ) { let view = VideoDownloadQualityView( downloadQuality: downloadQuality, - didSelect: didSelect + didSelect: didSelect, + analytics: analytics ) let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) diff --git a/Profile/Profile/Domain/Model/ProfileType.swift b/Profile/Profile/Domain/Model/ProfileType.swift index 3542c0b01..261c5c735 100644 --- a/Profile/Profile/Domain/Model/ProfileType.swift +++ b/Profile/Profile/Domain/Model/ProfileType.swift @@ -54,4 +54,8 @@ public enum ProfileType { return ProfileLocalization.fullProfile } } + + public var value: String? { + return String(describing: self).components(separatedBy: "(").first + } } diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift index df3b47d0d..ab1d61148 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift @@ -173,7 +173,8 @@ struct DeleteAccountView_Previews: PreviewProvider { let vm = DeleteAccountViewModel( interactor: ProfileInteractor.mock, router: router, - connectivity: Connectivity() + connectivity: Connectivity(), + analytics: ProfileAnalyticsMock() ) DeleteAccountView(viewModel: vm) diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountViewModel.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountViewModel.swift index 2cece2de0..298f30ca7 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountViewModel.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountViewModel.swift @@ -27,26 +27,37 @@ public class DeleteAccountViewModel: ObservableObject { private let interactor: ProfileInteractorProtocol public let router: ProfileRouter public let connectivity: ConnectivityProtocol + let analytics: ProfileAnalytics - public init(interactor: ProfileInteractorProtocol, router: ProfileRouter, connectivity: ConnectivityProtocol) { + public init( + interactor: ProfileInteractorProtocol, + router: ProfileRouter, + connectivity: ConnectivityProtocol, + analytics: ProfileAnalytics + ) { self.interactor = interactor self.router = router self.connectivity = connectivity + self.analytics = analytics } @MainActor func deleteAccount(password: String) async throws { isShowProgress = true + analytics.profileUserDeleteAccountClicked() do { if try await interactor.deleteAccount(password: password) { isShowProgress = false router.showLoginScreen(sourceScreen: .default) + analytics.profileDeleteAccountSuccess(success: true) } else { isShowProgress = false incorrectPassword = true + analytics.profileDeleteAccountSuccess(success: false) } } catch { isShowProgress = false + analytics.profileDeleteAccountSuccess(success: false) if error.validationError?.statusCode == 403 { incorrectPassword = true } else if let validationError = error.validationError, diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift b/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift index b134a7cd4..aee56c70c 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift @@ -164,6 +164,8 @@ public class EditProfileViewModel: ObservableObject { } else { profileChanges.profileType.toggle() } + + analytics.profileSwitch(action: profileChanges.profileType.value ?? "") } func checkProfileType() { diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index 2a158b211..f803e6a94 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -221,6 +221,7 @@ public struct ProfileView: View { private var logOutButton: some View { VStack { Button(action: { + viewModel.trackLogoutClickedClicked() viewModel.router.presentView( transitionStyle: .crossDissolve, animated: true diff --git a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift index 6fba1b825..d509224a6 100644 --- a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift +++ b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift @@ -140,6 +140,18 @@ public class ProfileViewModel: ObservableObject { analytics.cookiePolicyClicked() } + func trackTOSClicked() { + analytics.tosClicked() + } + + func trackFAQClicked() { + analytics.faqClicked() + } + + func trackDataSellClicked() { + analytics.dataSellClicked() + } + func trackPrivacyPolicyClicked() { analytics.privacyPolicyClicked() } @@ -147,4 +159,8 @@ public class ProfileViewModel: ObservableObject { func trackProfileEditClicked() { analytics.profileEditClicked() } + + func trackLogoutClickedClicked() { + analytics.profileEvent(.userLogoutClicked, biValue: .userLogoutClicked) + } } diff --git a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift index 134b38536..1b8c2ae63 100644 --- a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift +++ b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift @@ -9,6 +9,10 @@ import SwiftUI import Theme import Core +private enum SupportType { + case contactSupport, tos, privacyPolicy, cookiesPolicy, sellData, faq +} + struct ProfileSupportInfoView: View { struct LinkViewModel { @@ -48,6 +52,7 @@ struct ProfileSupportInfoView: View { title: ProfileLocalization.contact ), isEmailSupport: true, + supportType: .contactSupport, identifier: "contact_support" ) } @@ -57,7 +62,8 @@ struct ProfileSupportInfoView: View { viewModel: .init( url: url, title: ProfileLocalization.terms - ) + ), + type: .tos ) .accessibilityIdentifier("tos") } @@ -67,7 +73,8 @@ struct ProfileSupportInfoView: View { viewModel: .init( url: url, title: ProfileLocalization.privacy - ) + ), + type: .privacyPolicy ) .accessibilityIdentifier("privacy_policy") } @@ -77,7 +84,8 @@ struct ProfileSupportInfoView: View { viewModel: .init( url: url, title: ProfileLocalization.cookiePolicy - ) + ), + type: .cookiesPolicy ) .accessibilityIdentifier("cookies_policy") } @@ -87,7 +95,8 @@ struct ProfileSupportInfoView: View { viewModel: .init( url: url, title: ProfileLocalization.doNotSellInformation - ) + ), + type: .sellData ) .accessibilityIdentifier("dont_sell_data") } @@ -98,18 +107,20 @@ struct ProfileSupportInfoView: View { url: url, title: ProfileLocalization.faqTitle ), + supportType: .faq, identifier: "view_faq" ) } @ViewBuilder - private func navigationLink(viewModel: LinkViewModel) -> some View { + private func navigationLink(viewModel: LinkViewModel, type: SupportType) -> some View { NavigationLink { WebBrowser( url: viewModel.url.absoluteString, pageTitle: viewModel.title, showProgress: true ) + } label: { HStack { Text(viewModel.title) @@ -120,6 +131,21 @@ struct ProfileSupportInfoView: View { Image(systemName: "chevron.right") } } + .simultaneousGesture(TapGesture().onEnded { + switch type { + case .cookiesPolicy: + self.viewModel.trackCookiePolicyClicked() + case .tos: + self.viewModel.trackTOSClicked() + case .privacyPolicy: + self.viewModel.trackPrivacyPolicyClicked() + case .sellData: + self.viewModel.trackDataSellClicked() + + default: + break + } + }) .foregroundColor(.primary) .accessibilityElement(children: .ignore) .accessibilityLabel(viewModel.title) @@ -129,7 +155,12 @@ struct ProfileSupportInfoView: View { } @ViewBuilder - private func button(linkViewModel: LinkViewModel, isEmailSupport: Bool = false, identifier: String) -> some View { + private func button( + linkViewModel: LinkViewModel, + isEmailSupport: Bool = false, + supportType: SupportType, + identifier: String + ) -> some View { Button { guard UIApplication.shared.canOpenURL(linkViewModel.url) else { viewModel.errorMessage = isEmailSupport ? @@ -137,9 +168,16 @@ struct ProfileSupportInfoView: View { CoreLocalization.Error.unknownError return } - if isEmailSupport { + + switch supportType { + case .contactSupport: viewModel.trackEmailSupportClicked() + case .faq: + viewModel.trackFAQClicked() + default: + break } + UIApplication.shared.open(linkViewModel.url) } label: { HStack { diff --git a/Profile/Profile/Presentation/ProfileAnalytics.swift b/Profile/Profile/Presentation/ProfileAnalytics.swift index 58cc4b9d9..3713c15ba 100644 --- a/Profile/Profile/Presentation/ProfileAnalytics.swift +++ b/Profile/Profile/Presentation/ProfileAnalytics.swift @@ -6,28 +6,45 @@ // import Foundation +import Core //sourcery: AutoMockable public protocol ProfileAnalytics { func profileEditClicked() + func profileSwitch(action: String) func profileEditDoneClicked() func profileDeleteAccountClicked() func profileVideoSettingsClicked() func privacyPolicyClicked() func cookiePolicyClicked() func emailSupportClicked() + func faqClicked() + func tosClicked() + func dataSellClicked() func userLogout(force: Bool) + func profileWifiToggle(action: String) + func profileUserDeleteAccountClicked() + func profileDeleteAccountSuccess(success: Bool) + func profileEvent(_ event: AnalyticsEvent, biValue: EventBIValue) } #if DEBUG class ProfileAnalyticsMock: ProfileAnalytics { public func profileEditClicked() {} + public func profileSwitch(action: String) {} public func profileEditDoneClicked() {} public func profileDeleteAccountClicked() {} public func profileVideoSettingsClicked() {} public func privacyPolicyClicked() {} public func cookiePolicyClicked() {} public func emailSupportClicked() {} + public func faqClicked() {} + public func tosClicked() {} + public func dataSellClicked() {} public func userLogout(force: Bool) {} + public func profileWifiToggle(action: String) {} + public func profileUserDeleteAccountClicked() {} + public func profileDeleteAccountSuccess(success: Bool) {} + public func profileEvent(_ event: AnalyticsEvent, biValue: EventBIValue) {} } #endif diff --git a/Profile/Profile/Presentation/ProfileRouter.swift b/Profile/Profile/Presentation/ProfileRouter.swift index d449e2a3b..8d9539e92 100644 --- a/Profile/Profile/Presentation/ProfileRouter.swift +++ b/Profile/Profile/Presentation/ProfileRouter.swift @@ -24,7 +24,8 @@ public protocol ProfileRouter: BaseRouter { func showVideoDownloadQualityView( downloadQuality: DownloadQuality, - didSelect: ((DownloadQuality) -> Void)? + didSelect: ((DownloadQuality) -> Void)?, + analytics: CoreAnalytics ) func showDeleteProfileView() @@ -49,7 +50,8 @@ public class ProfileRouterMock: BaseRouterMock, ProfileRouter { public func showVideoDownloadQualityView( downloadQuality: DownloadQuality, - didSelect: ((DownloadQuality) -> Void)? + didSelect: ((DownloadQuality) -> Void)?, + analytics: CoreAnalytics ) {} public func showDeleteProfileView() {} diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index e695b0c88..d3ad59f26 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -66,7 +66,8 @@ public struct SettingsView: View { Button { viewModel.router.showVideoDownloadQualityView( downloadQuality: viewModel.userSettings.downloadQuality, - didSelect: viewModel.update(downloadQuality:) + didSelect: viewModel.update(downloadQuality:), + analytics: viewModel.analytics ) } label: { SettingsCell( @@ -123,7 +124,8 @@ struct SettingsView_Previews: PreviewProvider { let router = ProfileRouterMock() let vm = SettingsViewModel( interactor: ProfileInteractor.mock, - router: router + router: router, + analytics: CoreAnalyticsMock() ) SettingsView(viewModel: vm) diff --git a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift index b24eee494..499623a89 100644 --- a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift +++ b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift @@ -53,10 +53,12 @@ public class SettingsViewModel: ObservableObject { private let interactor: ProfileInteractorProtocol let router: ProfileRouter + let analytics: CoreAnalytics - public init(interactor: ProfileInteractorProtocol, router: ProfileRouter) { + public init(interactor: ProfileInteractorProtocol, router: ProfileRouter, analytics: CoreAnalytics) { self.interactor = interactor self.router = router + self.analytics = analytics let userSettings = interactor.getSettings() self.userSettings = userSettings diff --git a/Profile/Profile/Presentation/Settings/VideoQualityView.swift b/Profile/Profile/Presentation/Settings/VideoQualityView.swift index dcb9b66eb..8d1b03fda 100644 --- a/Profile/Profile/Presentation/Settings/VideoQualityView.swift +++ b/Profile/Profile/Presentation/Settings/VideoQualityView.swift @@ -33,6 +33,12 @@ public struct VideoQualityView: View { ForEach(viewModel.quality, id: \.offset) { _, quality in Button(action: { + viewModel.analytics.videoQualityChanged( + .videoStreamQualityChanged, + bivalue: .videoStreamQualityChanged, + value: quality.value ?? "", + oldValue: viewModel.selectedQuality.value ?? "" + ) viewModel.selectedQuality = quality }, label: { HStack { @@ -86,8 +92,11 @@ public struct VideoQualityView: View { struct VideoQualityView_Previews: PreviewProvider { static var previews: some View { let router = ProfileRouterMock() - let vm = SettingsViewModel(interactor: ProfileInteractor.mock, - router: router) + let vm = SettingsViewModel( + interactor: ProfileInteractor.mock, + router: router, + analytics: CoreAnalyticsMock() + ) VideoQualityView(viewModel: vm) .preferredColorScheme(.light) diff --git a/Profile/ProfileTests/Presentation/DeleteAccount/DeleteAccountViewModelTests.swift b/Profile/ProfileTests/Presentation/DeleteAccount/DeleteAccountViewModelTests.swift index ca284bdf8..c6ba81755 100644 --- a/Profile/ProfileTests/Presentation/DeleteAccount/DeleteAccountViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/DeleteAccount/DeleteAccountViewModelTests.swift @@ -18,7 +18,12 @@ final class DeleteAccountViewModelTests: XCTestCase { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = DeleteAccountViewModel(interactor: interactor, router: router, connectivity: connectivity) + let viewModel = DeleteAccountViewModel( + interactor: interactor, + router: router, + connectivity: connectivity, + analytics: ProfileAnalyticsMock() + ) Given(interactor, .deleteAccount(password: .any, willReturn: true)) @@ -32,7 +37,12 @@ final class DeleteAccountViewModelTests: XCTestCase { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = DeleteAccountViewModel(interactor: interactor, router: router, connectivity: connectivity) + let viewModel = DeleteAccountViewModel( + interactor: interactor, + router: router, + connectivity: connectivity, + analytics: ProfileAnalyticsMock() + ) Given(interactor, .deleteAccount(password: .any, willReturn: false)) @@ -48,7 +58,12 @@ final class DeleteAccountViewModelTests: XCTestCase { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = DeleteAccountViewModel(interactor: interactor, router: router, connectivity: connectivity) + let viewModel = DeleteAccountViewModel( + interactor: interactor, + router: router, + connectivity: connectivity, + analytics: ProfileAnalyticsMock() + ) let validationError = CustomValidationError(statusCode: 401, data: ["error_code": "user_not_active"]) let error = AFError.responseValidationFailed( @@ -71,7 +86,12 @@ final class DeleteAccountViewModelTests: XCTestCase { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = DeleteAccountViewModel(interactor: interactor, router: router, connectivity: connectivity) + let viewModel = DeleteAccountViewModel( + interactor: interactor, + router: router, + connectivity: connectivity, + analytics: ProfileAnalyticsMock() + ) Given(interactor, .deleteAccount(password: .any, willThrow: NSError())) @@ -89,7 +109,12 @@ final class DeleteAccountViewModelTests: XCTestCase { let interactor = ProfileInteractorProtocolMock() let router = ProfileRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = DeleteAccountViewModel(interactor: interactor, router: router, connectivity: connectivity) + let viewModel = DeleteAccountViewModel( + interactor: interactor, + router: router, + connectivity: connectivity, + analytics: ProfileAnalyticsMock() + ) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 614d436b5..b0f2d231b 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -1114,6 +1114,281 @@ open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { } } +// MARK: - CoreAnalytics + +open class CoreAnalyticsMock: CoreAnalytics, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func trackEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, [String: Any]?) -> Void + perform?(`event`, `parameters`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { + addInvocation(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void + perform?(`event`, `biValue`, `parameters`) + } + + open func appreview(_ event: AnalyticsEvent, biValue: EventBIValue, action: String?, rating: Int?) { + addInvocation(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) + let perform = methodPerformValue(.m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`action`), Parameter.value(`rating`))) as? (AnalyticsEvent, EventBIValue, String?, Int?) -> Void + perform?(`event`, `biValue`, `action`, `rating`) + } + + open func videoQualityChanged(_ event: AnalyticsEvent, bivalue: EventBIValue, value: String, oldValue: String) { + addInvocation(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) + let perform = methodPerformValue(.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter.value(`event`), Parameter.value(`bivalue`), Parameter.value(`value`), Parameter.value(`oldValue`))) as? (AnalyticsEvent, EventBIValue, String, String) -> Void + perform?(`event`, `bivalue`, `value`, `oldValue`) + } + + open func trackEvent(_ event: AnalyticsEvent) { + addInvocation(.m_trackEvent__event(Parameter.value(`event`))) + let perform = methodPerformValue(.m_trackEvent__event(Parameter.value(`event`))) as? (AnalyticsEvent) -> Void + perform?(`event`) + } + + open func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_trackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void + perform?(`event`, `biValue`) + } + + + fileprivate enum MethodType { + case m_trackEvent__eventparameters_parameters(Parameter, Parameter<[String: Any]?>) + case m_trackEvent__eventbiValue_biValueparameters_parameters(Parameter, Parameter, Parameter<[String: Any]?>) + case m_appreview__eventbiValue_biValueaction_actionrating_rating(Parameter, Parameter, Parameter, Parameter) + case m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(Parameter, Parameter, Parameter, Parameter) + case m_trackEvent__event(Parameter) + case m_trackEvent__eventbiValue_biValue(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_trackEvent__eventparameters_parameters(let lhsEvent, let lhsParameters), .m_trackEvent__eventparameters_parameters(let rhsEvent, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValueparameters_parameters(let lhsEvent, let lhsBivalue, let lhsParameters), .m_trackEvent__eventbiValue_biValueparameters_parameters(let rhsEvent, let rhsBivalue, let rhsParameters)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + return Matcher.ComparisonResult(results) + + case (.m_appreview__eventbiValue_biValueaction_actionrating_rating(let lhsEvent, let lhsBivalue, let lhsAction, let lhsRating), .m_appreview__eventbiValue_biValueaction_actionrating_rating(let rhsEvent, let rhsBivalue, let rhsAction, let rhsRating)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRating, rhs: rhsRating, with: matcher), lhsRating, rhsRating, "rating")) + return Matcher.ComparisonResult(results) + + case (.m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let lhsEvent, let lhsBivalue, let lhsValue, let lhsOldvalue), .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(let rhsEvent, let rhsBivalue, let rhsValue, let rhsOldvalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "bivalue")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsValue, rhs: rhsValue, with: matcher), lhsValue, rhsValue, "value")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOldvalue, rhs: rhsOldvalue, with: matcher), lhsOldvalue, rhsOldvalue, "oldValue")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__event(let lhsEvent), .m_trackEvent__event(let rhsEvent)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + return Matcher.ComparisonResult(results) + + case (.m_trackEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_trackEvent__eventbiValue_biValue(let rhsEvent, let rhsBivalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_trackEvent__eventparameters_parameters(p0, p1): return p0.intValue + p1.intValue + case let .m_trackEvent__eventbiValue_biValueparameters_parameters(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_appreview__eventbiValue_biValueaction_actionrating_rating(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_trackEvent__event(p0): return p0.intValue + case let .m_trackEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_trackEvent__eventparameters_parameters: return ".trackEvent(_:parameters:)" + case .m_trackEvent__eventbiValue_biValueparameters_parameters: return ".trackEvent(_:biValue:parameters:)" + case .m_appreview__eventbiValue_biValueaction_actionrating_rating: return ".appreview(_:biValue:action:rating:)" + case .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue: return ".videoQualityChanged(_:bivalue:value:oldValue:)" + case .m_trackEvent__event: return ".trackEvent(_:)" + case .m_trackEvent__eventbiValue_biValue: return ".trackEvent(_:biValue:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`))} + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter) -> Verify { return Verify(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`))} + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter) -> Verify { return Verify(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`))} + public static func trackEvent(_ event: Parameter) -> Verify { return Verify(method: .m_trackEvent__event(`event`))} + public static func trackEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func trackEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventparameters_parameters(`event`, `parameters`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`), performs: perform) + } + public static func appreview(_ event: Parameter, biValue: Parameter, action: Parameter, rating: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String?, Int?) -> Void) -> Perform { + return Perform(method: .m_appreview__eventbiValue_biValueaction_actionrating_rating(`event`, `biValue`, `action`, `rating`), performs: perform) + } + public static func videoQualityChanged(_ event: Parameter, bivalue: Parameter, value: Parameter, oldValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String, String) -> Void) -> Perform { + return Perform(method: .m_videoQualityChanged__eventbivalue_bivaluevalue_valueoldValue_oldValue(`event`, `bivalue`, `value`, `oldValue`), performs: perform) + } + public static func trackEvent(_ event: Parameter, perform: @escaping (AnalyticsEvent) -> Void) -> Perform { + return Perform(method: .m_trackEvent__event(`event`), performs: perform) + } + public static func trackEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_trackEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + // MARK: - DownloadManagerProtocol open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { @@ -1778,6 +2053,12 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { perform?() } + open func profileSwitch(action: String) { + addInvocation(.m_profileSwitch__action_action(Parameter.value(`action`))) + let perform = methodPerformValue(.m_profileSwitch__action_action(Parameter.value(`action`))) as? (String) -> Void + perform?(`action`) + } + open func profileEditDoneClicked() { addInvocation(.m_profileEditDoneClicked) let perform = methodPerformValue(.m_profileEditDoneClicked) as? () -> Void @@ -1814,27 +2095,82 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { perform?() } + open func faqClicked() { + addInvocation(.m_faqClicked) + let perform = methodPerformValue(.m_faqClicked) as? () -> Void + perform?() + } + + open func tosClicked() { + addInvocation(.m_tosClicked) + let perform = methodPerformValue(.m_tosClicked) as? () -> Void + perform?() + } + + open func dataSellClicked() { + addInvocation(.m_dataSellClicked) + let perform = methodPerformValue(.m_dataSellClicked) as? () -> Void + perform?() + } + open func userLogout(force: Bool) { addInvocation(.m_userLogout__force_force(Parameter.value(`force`))) let perform = methodPerformValue(.m_userLogout__force_force(Parameter.value(`force`))) as? (Bool) -> Void perform?(`force`) } + open func profileWifiToggle(action: String) { + addInvocation(.m_profileWifiToggle__action_action(Parameter.value(`action`))) + let perform = methodPerformValue(.m_profileWifiToggle__action_action(Parameter.value(`action`))) as? (String) -> Void + perform?(`action`) + } + + open func profileUserDeleteAccountClicked() { + addInvocation(.m_profileUserDeleteAccountClicked) + let perform = methodPerformValue(.m_profileUserDeleteAccountClicked) as? () -> Void + perform?() + } + + open func profileDeleteAccountSuccess(success: Bool) { + addInvocation(.m_profileDeleteAccountSuccess__success_success(Parameter.value(`success`))) + let perform = methodPerformValue(.m_profileDeleteAccountSuccess__success_success(Parameter.value(`success`))) as? (Bool) -> Void + perform?(`success`) + } + + open func profileEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_profileEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_profileEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void + perform?(`event`, `biValue`) + } + fileprivate enum MethodType { case m_profileEditClicked + case m_profileSwitch__action_action(Parameter) case m_profileEditDoneClicked case m_profileDeleteAccountClicked case m_profileVideoSettingsClicked case m_privacyPolicyClicked case m_cookiePolicyClicked case m_emailSupportClicked + case m_faqClicked + case m_tosClicked + case m_dataSellClicked case m_userLogout__force_force(Parameter) + case m_profileWifiToggle__action_action(Parameter) + case m_profileUserDeleteAccountClicked + case m_profileDeleteAccountSuccess__success_success(Parameter) + case m_profileEvent__eventbiValue_biValue(Parameter, Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { case (.m_profileEditClicked, .m_profileEditClicked): return .match + case (.m_profileSwitch__action_action(let lhsAction), .m_profileSwitch__action_action(let rhsAction)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + return Matcher.ComparisonResult(results) + case (.m_profileEditDoneClicked, .m_profileEditDoneClicked): return .match case (.m_profileDeleteAccountClicked, .m_profileDeleteAccountClicked): return .match @@ -1847,10 +2183,34 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { case (.m_emailSupportClicked, .m_emailSupportClicked): return .match + case (.m_faqClicked, .m_faqClicked): return .match + + case (.m_tosClicked, .m_tosClicked): return .match + + case (.m_dataSellClicked, .m_dataSellClicked): return .match + case (.m_userLogout__force_force(let lhsForce), .m_userLogout__force_force(let rhsForce)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsForce, rhs: rhsForce, with: matcher), lhsForce, rhsForce, "force")) return Matcher.ComparisonResult(results) + + case (.m_profileWifiToggle__action_action(let lhsAction), .m_profileWifiToggle__action_action(let rhsAction)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + return Matcher.ComparisonResult(results) + + case (.m_profileUserDeleteAccountClicked, .m_profileUserDeleteAccountClicked): return .match + + case (.m_profileDeleteAccountSuccess__success_success(let lhsSuccess), .m_profileDeleteAccountSuccess__success_success(let rhsSuccess)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSuccess, rhs: rhsSuccess, with: matcher), lhsSuccess, rhsSuccess, "success")) + return Matcher.ComparisonResult(results) + + case (.m_profileEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_profileEvent__eventbiValue_biValue(let rhsEvent, let rhsBivalue)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBivalue, rhs: rhsBivalue, with: matcher), lhsBivalue, rhsBivalue, "biValue")) + return Matcher.ComparisonResult(results) default: return .none } } @@ -1858,25 +2218,41 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { func intValue() -> Int { switch self { case .m_profileEditClicked: return 0 + case let .m_profileSwitch__action_action(p0): return p0.intValue case .m_profileEditDoneClicked: return 0 case .m_profileDeleteAccountClicked: return 0 case .m_profileVideoSettingsClicked: return 0 case .m_privacyPolicyClicked: return 0 case .m_cookiePolicyClicked: return 0 case .m_emailSupportClicked: return 0 + case .m_faqClicked: return 0 + case .m_tosClicked: return 0 + case .m_dataSellClicked: return 0 case let .m_userLogout__force_force(p0): return p0.intValue + case let .m_profileWifiToggle__action_action(p0): return p0.intValue + case .m_profileUserDeleteAccountClicked: return 0 + case let .m_profileDeleteAccountSuccess__success_success(p0): return p0.intValue + case let .m_profileEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue } } func assertionName() -> String { switch self { case .m_profileEditClicked: return ".profileEditClicked()" + case .m_profileSwitch__action_action: return ".profileSwitch(action:)" case .m_profileEditDoneClicked: return ".profileEditDoneClicked()" case .m_profileDeleteAccountClicked: return ".profileDeleteAccountClicked()" case .m_profileVideoSettingsClicked: return ".profileVideoSettingsClicked()" case .m_privacyPolicyClicked: return ".privacyPolicyClicked()" case .m_cookiePolicyClicked: return ".cookiePolicyClicked()" case .m_emailSupportClicked: return ".emailSupportClicked()" + case .m_faqClicked: return ".faqClicked()" + case .m_tosClicked: return ".tosClicked()" + case .m_dataSellClicked: return ".dataSellClicked()" case .m_userLogout__force_force: return ".userLogout(force:)" + case .m_profileWifiToggle__action_action: return ".profileWifiToggle(action:)" + case .m_profileUserDeleteAccountClicked: return ".profileUserDeleteAccountClicked()" + case .m_profileDeleteAccountSuccess__success_success: return ".profileDeleteAccountSuccess(success:)" + case .m_profileEvent__eventbiValue_biValue: return ".profileEvent(_:biValue:)" } } } @@ -1896,13 +2272,21 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { fileprivate var method: MethodType public static func profileEditClicked() -> Verify { return Verify(method: .m_profileEditClicked)} + public static func profileSwitch(action: Parameter) -> Verify { return Verify(method: .m_profileSwitch__action_action(`action`))} public static func profileEditDoneClicked() -> Verify { return Verify(method: .m_profileEditDoneClicked)} public static func profileDeleteAccountClicked() -> Verify { return Verify(method: .m_profileDeleteAccountClicked)} public static func profileVideoSettingsClicked() -> Verify { return Verify(method: .m_profileVideoSettingsClicked)} public static func privacyPolicyClicked() -> Verify { return Verify(method: .m_privacyPolicyClicked)} public static func cookiePolicyClicked() -> Verify { return Verify(method: .m_cookiePolicyClicked)} public static func emailSupportClicked() -> Verify { return Verify(method: .m_emailSupportClicked)} + public static func faqClicked() -> Verify { return Verify(method: .m_faqClicked)} + public static func tosClicked() -> Verify { return Verify(method: .m_tosClicked)} + public static func dataSellClicked() -> Verify { return Verify(method: .m_dataSellClicked)} public static func userLogout(force: Parameter) -> Verify { return Verify(method: .m_userLogout__force_force(`force`))} + public static func profileWifiToggle(action: Parameter) -> Verify { return Verify(method: .m_profileWifiToggle__action_action(`action`))} + public static func profileUserDeleteAccountClicked() -> Verify { return Verify(method: .m_profileUserDeleteAccountClicked)} + public static func profileDeleteAccountSuccess(success: Parameter) -> Verify { return Verify(method: .m_profileDeleteAccountSuccess__success_success(`success`))} + public static func profileEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_profileEvent__eventbiValue_biValue(`event`, `biValue`))} } public struct Perform { @@ -1912,6 +2296,9 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { public static func profileEditClicked(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_profileEditClicked, performs: perform) } + public static func profileSwitch(action: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_profileSwitch__action_action(`action`), performs: perform) + } public static func profileEditDoneClicked(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_profileEditDoneClicked, performs: perform) } @@ -1930,9 +2317,30 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { public static func emailSupportClicked(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_emailSupportClicked, performs: perform) } + public static func faqClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_faqClicked, performs: perform) + } + public static func tosClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_tosClicked, performs: perform) + } + public static func dataSellClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_dataSellClicked, performs: perform) + } public static func userLogout(force: Parameter, perform: @escaping (Bool) -> Void) -> Perform { return Perform(method: .m_userLogout__force_force(`force`), performs: perform) } + public static func profileWifiToggle(action: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_profileWifiToggle__action_action(`action`), performs: perform) + } + public static func profileUserDeleteAccountClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_profileUserDeleteAccountClicked, performs: perform) + } + public static func profileDeleteAccountSuccess(success: Parameter, perform: @escaping (Bool) -> Void) -> Perform { + return Perform(method: .m_profileDeleteAccountSuccess__success_success(`success`), performs: perform) + } + public static func profileEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_profileEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } } public func given(_ method: Given) { @@ -2642,10 +3050,10 @@ open class ProfileRouterMock: ProfileRouter, Mock { perform?(`viewModel`) } - open func showVideoDownloadQualityView(downloadQuality: DownloadQuality, didSelect: ((DownloadQuality) -> Void)?) { - addInvocation(.m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(Parameter.value(`downloadQuality`), Parameter<((DownloadQuality) -> Void)?>.value(`didSelect`))) - let perform = methodPerformValue(.m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(Parameter.value(`downloadQuality`), Parameter<((DownloadQuality) -> Void)?>.value(`didSelect`))) as? (DownloadQuality, ((DownloadQuality) -> Void)?) -> Void - perform?(`downloadQuality`, `didSelect`) + open func showVideoDownloadQualityView(downloadQuality: DownloadQuality, didSelect: ((DownloadQuality) -> Void)?, analytics: CoreAnalytics) { + addInvocation(.m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics(Parameter.value(`downloadQuality`), Parameter<((DownloadQuality) -> Void)?>.value(`didSelect`), Parameter.value(`analytics`))) + let perform = methodPerformValue(.m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics(Parameter.value(`downloadQuality`), Parameter<((DownloadQuality) -> Void)?>.value(`didSelect`), Parameter.value(`analytics`))) as? (DownloadQuality, ((DownloadQuality) -> Void)?, CoreAnalytics) -> Void + perform?(`downloadQuality`, `didSelect`, `analytics`) } open func showDeleteProfileView() { @@ -2755,7 +3163,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case m_showEditProfile__userModel_userModelavatar_avatarprofileDidEdit_profileDidEdit(Parameter, Parameter, Parameter<((UserProfile?, UIImage?)) -> Void>) case m_showSettings case m_showVideoQualityView__viewModel_viewModel(Parameter) - case m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(Parameter, Parameter<((DownloadQuality) -> Void)?>) + case m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics(Parameter, Parameter<((DownloadQuality) -> Void)?>, Parameter) case m_showDeleteProfileView case m_backToRoot__animated_animated(Parameter) case m_back__animated_animated(Parameter) @@ -2790,10 +3198,11 @@ open class ProfileRouterMock: ProfileRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsViewmodel, rhs: rhsViewmodel, with: matcher), lhsViewmodel, rhsViewmodel, "viewModel")) return Matcher.ComparisonResult(results) - case (.m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(let lhsDownloadquality, let lhsDidselect), .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(let rhsDownloadquality, let rhsDidselect)): + case (.m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics(let lhsDownloadquality, let lhsDidselect, let lhsAnalytics), .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics(let rhsDownloadquality, let rhsDidselect, let rhsAnalytics)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDownloadquality, rhs: rhsDownloadquality, with: matcher), lhsDownloadquality, rhsDownloadquality, "downloadQuality")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDidselect, rhs: rhsDidselect, with: matcher), lhsDidselect, rhsDidselect, "didSelect")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAnalytics, rhs: rhsAnalytics, with: matcher), lhsAnalytics, rhsAnalytics, "analytics")) return Matcher.ComparisonResult(results) case (.m_showDeleteProfileView, .m_showDeleteProfileView): return .match @@ -2894,7 +3303,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case let .m_showEditProfile__userModel_userModelavatar_avatarprofileDidEdit_profileDidEdit(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case .m_showSettings: return 0 case let .m_showVideoQualityView__viewModel_viewModel(p0): return p0.intValue - case let .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(p0, p1): return p0.intValue + p1.intValue + case let .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case .m_showDeleteProfileView: return 0 case let .m_backToRoot__animated_animated(p0): return p0.intValue case let .m_back__animated_animated(p0): return p0.intValue @@ -2919,7 +3328,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case .m_showEditProfile__userModel_userModelavatar_avatarprofileDidEdit_profileDidEdit: return ".showEditProfile(userModel:avatar:profileDidEdit:)" case .m_showSettings: return ".showSettings()" case .m_showVideoQualityView__viewModel_viewModel: return ".showVideoQualityView(viewModel:)" - case .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect: return ".showVideoDownloadQualityView(downloadQuality:didSelect:)" + case .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics: return ".showVideoDownloadQualityView(downloadQuality:didSelect:analytics:)" case .m_showDeleteProfileView: return ".showDeleteProfileView()" case .m_backToRoot__animated_animated: return ".backToRoot(animated:)" case .m_back__animated_animated: return ".back(animated:)" @@ -2958,7 +3367,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { public static func showEditProfile(userModel: Parameter, avatar: Parameter, profileDidEdit: Parameter<((UserProfile?, UIImage?)) -> Void>) -> Verify { return Verify(method: .m_showEditProfile__userModel_userModelavatar_avatarprofileDidEdit_profileDidEdit(`userModel`, `avatar`, `profileDidEdit`))} public static func showSettings() -> Verify { return Verify(method: .m_showSettings)} public static func showVideoQualityView(viewModel: Parameter) -> Verify { return Verify(method: .m_showVideoQualityView__viewModel_viewModel(`viewModel`))} - public static func showVideoDownloadQualityView(downloadQuality: Parameter, didSelect: Parameter<((DownloadQuality) -> Void)?>) -> Verify { return Verify(method: .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(`downloadQuality`, `didSelect`))} + public static func showVideoDownloadQualityView(downloadQuality: Parameter, didSelect: Parameter<((DownloadQuality) -> Void)?>, analytics: Parameter) -> Verify { return Verify(method: .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics(`downloadQuality`, `didSelect`, `analytics`))} public static func showDeleteProfileView() -> Verify { return Verify(method: .m_showDeleteProfileView)} public static func backToRoot(animated: Parameter) -> Verify { return Verify(method: .m_backToRoot__animated_animated(`animated`))} public static func back(animated: Parameter) -> Verify { return Verify(method: .m_back__animated_animated(`animated`))} @@ -2991,8 +3400,8 @@ open class ProfileRouterMock: ProfileRouter, Mock { public static func showVideoQualityView(viewModel: Parameter, perform: @escaping (SettingsViewModel) -> Void) -> Perform { return Perform(method: .m_showVideoQualityView__viewModel_viewModel(`viewModel`), performs: perform) } - public static func showVideoDownloadQualityView(downloadQuality: Parameter, didSelect: Parameter<((DownloadQuality) -> Void)?>, perform: @escaping (DownloadQuality, ((DownloadQuality) -> Void)?) -> Void) -> Perform { - return Perform(method: .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelect(`downloadQuality`, `didSelect`), performs: perform) + public static func showVideoDownloadQualityView(downloadQuality: Parameter, didSelect: Parameter<((DownloadQuality) -> Void)?>, analytics: Parameter, perform: @escaping (DownloadQuality, ((DownloadQuality) -> Void)?, CoreAnalytics) -> Void) -> Perform { + return Perform(method: .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics(`downloadQuality`, `didSelect`, `analytics`), performs: perform) } public static func showDeleteProfileView(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showDeleteProfileView, performs: perform) diff --git a/WhatsNew/WhatsNew.xcodeproj/project.pbxproj b/WhatsNew/WhatsNew.xcodeproj/project.pbxproj index dad830243..b78d64a7d 100644 --- a/WhatsNew/WhatsNew.xcodeproj/project.pbxproj +++ b/WhatsNew/WhatsNew.xcodeproj/project.pbxproj @@ -24,6 +24,7 @@ 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 */; }; + 14769D3E2B99713800AB36D4 /* WhatsNewAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14769D3D2B99713800AB36D4 /* WhatsNewAnalytics.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 */ @@ -61,6 +62,7 @@ 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 = ""; }; + 14769D3D2B99713800AB36D4 /* WhatsNewAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewAnalytics.swift; 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 = ""; }; @@ -187,6 +189,7 @@ 02E640782ADFF5920079AEDA /* WhatsNewView.swift */, 02E640802ADFFE440079AEDA /* WhatsNewViewModel.swift */, 02B54E102AE061C100C56962 /* WhatsNewRouter.swift */, + 14769D3D2B99713800AB36D4 /* WhatsNewAnalytics.swift */, ); path = Presentation; sourceTree = ""; @@ -438,6 +441,7 @@ 020A7B5F2AE131A9000BAF70 /* WhatsNewModel.swift in Sources */, 02B54E0F2AE0337800C56962 /* PageControl.swift in Sources */, 02EC90AC2AE90C64007DE1E0 /* WhatsNewPage.swift in Sources */, + 14769D3E2B99713800AB36D4 /* WhatsNewAnalytics.swift in Sources */, 02EC90AA2AE904E1007DE1E0 /* WhatsNewStorage.swift in Sources */, 02E6408A2AE004300079AEDA /* Strings.swift in Sources */, 02E640792ADFF5920079AEDA /* WhatsNewView.swift in Sources */, diff --git a/WhatsNew/WhatsNew/Presentation/WhatsNewAnalytics.swift b/WhatsNew/WhatsNew/Presentation/WhatsNewAnalytics.swift new file mode 100644 index 000000000..547afd85c --- /dev/null +++ b/WhatsNew/WhatsNew/Presentation/WhatsNewAnalytics.swift @@ -0,0 +1,23 @@ +// +// WhatsNewAnalytics.swift +// WhatsNew +// +// Created by Saeed Bashir on 3/7/24. +// + +import Foundation + +//sourcery: AutoMockable +public protocol WhatsNewAnalytics { + func whatsnewPopup() + func whatsnewDone(totalScreens: Int) + func whatsnewClose(totalScreens: Int, currentScreen: Int) +} + +#if DEBUG +class WhatsNewAnalyticsMock: WhatsNewAnalytics { + public func whatsnewPopup() {} + public func whatsnewDone(totalScreens: Int) {} + public func whatsnewClose(totalScreens: Int, currentScreen: Int) {} +} +#endif diff --git a/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift b/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift index 782315d43..4fbd03c4e 100644 --- a/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift +++ b/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift @@ -112,6 +112,10 @@ public struct WhatsNewView: View { } else { router.showMainOrWhatsNewScreen(sourceScreen: viewModel.sourceScreen) } + + if viewModel.index == viewModel.newItems.count - 1 { + viewModel.logWhatsNewDone() + } } ) .accessibilityIdentifier("next_button") @@ -143,6 +147,7 @@ public struct WhatsNewView: View { ToolbarItem(placement: .navigationBarTrailing, content: { Button(action: { router.showMainOrWhatsNewScreen(sourceScreen: viewModel.sourceScreen) + viewModel.logWhatsNewClose() }, label: { Image(systemName: "xmark") .foregroundColor(Theme.Colors.accentXColor) @@ -150,6 +155,9 @@ public struct WhatsNewView: View { .accessibilityIdentifier("close_button") }) } + .onFirstAppear { + viewModel.logWhatsNewPopup() + } } } @@ -161,7 +169,10 @@ struct WhatsNewView_Previews: PreviewProvider { static var previews: some View { WhatsNewView( router: WhatsNewRouterMock(), - viewModel: WhatsNewViewModel(storage: WhatsNewStorageMock()) + viewModel: WhatsNewViewModel( + storage: WhatsNewStorageMock(), + analytics: WhatsNewAnalyticsMock() + ) ) .loadFonts() } diff --git a/WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift b/WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift index 12eb34b78..212ea92fb 100644 --- a/WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift +++ b/WhatsNew/WhatsNew/Presentation/WhatsNewViewModel.swift @@ -14,10 +14,16 @@ public class WhatsNewViewModel: ObservableObject { @Published var newItems: [WhatsNewPage] = [] private let storage: WhatsNewStorage var sourceScreen: LogistrationSourceScreen + let analytics: WhatsNewAnalytics - public init(storage: WhatsNewStorage, sourceScreen: LogistrationSourceScreen = .default) { + public init( + storage: WhatsNewStorage, + sourceScreen: LogistrationSourceScreen = .default, + analytics: WhatsNewAnalytics + ) { self.storage = storage self.sourceScreen = sourceScreen + self.analytics = analytics newItems = loadWhatsNew() } @@ -72,4 +78,21 @@ public class WhatsNewViewModel: ObservableObject { return nil } } + + private let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + + func logWhatsNewPopup() { + analytics.whatsnewPopup() + } + + func logWhatsNewDone() { + let total = newItems.count + analytics.whatsnewDone(totalScreens: total) + } + + func logWhatsNewClose() { + let total = newItems.count + + analytics.whatsnewClose(totalScreens: total, currentScreen: index + 1) + } } diff --git a/WhatsNew/WhatsNewTests/Presentation/WhatsNewTests.swift b/WhatsNew/WhatsNewTests/Presentation/WhatsNewTests.swift index 7c6542b16..f7453a662 100644 --- a/WhatsNew/WhatsNewTests/Presentation/WhatsNewTests.swift +++ b/WhatsNew/WhatsNewTests/Presentation/WhatsNewTests.swift @@ -12,14 +12,20 @@ import Core final class WhatsNewTests: XCTestCase { func testGetVersion() throws { - let viewModel = WhatsNewViewModel(storage: WhatsNewStorageMock()) + let viewModel = WhatsNewViewModel( + storage: WhatsNewStorageMock(), + analytics: WhatsNewAnalyticsMock() + ) let version = viewModel.getVersion() XCTAssertNotNil(version) XCTAssertTrue(version == "1.0") } func testshouldShowWhatsNew() throws { - let viewModel = WhatsNewViewModel(storage: WhatsNewStorageMock()) + let viewModel = WhatsNewViewModel( + storage: WhatsNewStorageMock(), + analytics: WhatsNewAnalyticsMock() + ) let version = viewModel.getVersion() XCTAssertNotNil(version) XCTAssertTrue(viewModel.shouldShowWhatsNew()) diff --git a/WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift b/WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift index 999f7cc25..dca08a91e 100644 --- a/WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift +++ b/WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift @@ -15,5 +15,212 @@ import SwiftUI import Combine -// SwiftyMocky: no AutoMockable found. -// Please define and inherit from AutoMockable, or annotate protocols to be mocked +// MARK: - WhatsNewAnalytics + +open class WhatsNewAnalyticsMock: WhatsNewAnalytics, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func whatsnewPopup() { + addInvocation(.m_whatsnewPopup) + let perform = methodPerformValue(.m_whatsnewPopup) as? () -> Void + perform?() + } + + open func whatsnewDone(totalScreens: Int) { + addInvocation(.m_whatsnewDone__totalScreens_totalScreens(Parameter.value(`totalScreens`))) + let perform = methodPerformValue(.m_whatsnewDone__totalScreens_totalScreens(Parameter.value(`totalScreens`))) as? (Int) -> Void + perform?(`totalScreens`) + } + + open func whatsnewClose(totalScreens: Int, currentScreen: Int) { + addInvocation(.m_whatsnewClose__totalScreens_totalScreenscurrentScreen_currentScreen(Parameter.value(`totalScreens`), Parameter.value(`currentScreen`))) + let perform = methodPerformValue(.m_whatsnewClose__totalScreens_totalScreenscurrentScreen_currentScreen(Parameter.value(`totalScreens`), Parameter.value(`currentScreen`))) as? (Int, Int) -> Void + perform?(`totalScreens`, `currentScreen`) + } + + + fileprivate enum MethodType { + case m_whatsnewPopup + case m_whatsnewDone__totalScreens_totalScreens(Parameter) + case m_whatsnewClose__totalScreens_totalScreenscurrentScreen_currentScreen(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_whatsnewPopup, .m_whatsnewPopup): return .match + + case (.m_whatsnewDone__totalScreens_totalScreens(let lhsTotalscreens), .m_whatsnewDone__totalScreens_totalScreens(let rhsTotalscreens)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTotalscreens, rhs: rhsTotalscreens, with: matcher), lhsTotalscreens, rhsTotalscreens, "totalScreens")) + return Matcher.ComparisonResult(results) + + case (.m_whatsnewClose__totalScreens_totalScreenscurrentScreen_currentScreen(let lhsTotalscreens, let lhsCurrentscreen), .m_whatsnewClose__totalScreens_totalScreenscurrentScreen_currentScreen(let rhsTotalscreens, let rhsCurrentscreen)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTotalscreens, rhs: rhsTotalscreens, with: matcher), lhsTotalscreens, rhsTotalscreens, "totalScreens")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCurrentscreen, rhs: rhsCurrentscreen, with: matcher), lhsCurrentscreen, rhsCurrentscreen, "currentScreen")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .m_whatsnewPopup: return 0 + case let .m_whatsnewDone__totalScreens_totalScreens(p0): return p0.intValue + case let .m_whatsnewClose__totalScreens_totalScreenscurrentScreen_currentScreen(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_whatsnewPopup: return ".whatsnewPopup()" + case .m_whatsnewDone__totalScreens_totalScreens: return ".whatsnewDone(totalScreens:)" + case .m_whatsnewClose__totalScreens_totalScreenscurrentScreen_currentScreen: return ".whatsnewClose(totalScreens:currentScreen:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func whatsnewPopup() -> Verify { return Verify(method: .m_whatsnewPopup)} + public static func whatsnewDone(totalScreens: Parameter) -> Verify { return Verify(method: .m_whatsnewDone__totalScreens_totalScreens(`totalScreens`))} + public static func whatsnewClose(totalScreens: Parameter, currentScreen: Parameter) -> Verify { return Verify(method: .m_whatsnewClose__totalScreens_totalScreenscurrentScreen_currentScreen(`totalScreens`, `currentScreen`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func whatsnewPopup(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_whatsnewPopup, performs: perform) + } + public static func whatsnewDone(totalScreens: Parameter, perform: @escaping (Int) -> Void) -> Perform { + return Perform(method: .m_whatsnewDone__totalScreens_totalScreens(`totalScreens`), performs: perform) + } + public static func whatsnewClose(totalScreens: Parameter, currentScreen: Parameter, perform: @escaping (Int, Int) -> Void) -> Perform { + return Perform(method: .m_whatsnewClose__totalScreens_totalScreenscurrentScreen_currentScreen(`totalScreens`, `currentScreen`), performs: perform) + } + } + + public func given(_ method: Given) { + methodReturnValues.append(method) + } + + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + } + + public func verify(_ method: Verify, count: Count = Count.moreOrEqual(to: 1), file: StaticString = #file, line: UInt = #line) { + let fullMatches = matchingCalls(method, file: file, line: line) + let success = count.matches(fullMatches) + let assertionName = method.method.assertionName() + let feedback: String = { + guard !success else { return "" } + return Utils.closestCallsMessage( + for: self.invocations.map { invocation in + matcher.set(file: file, line: line) + defer { matcher.clearFileAndLine() } + return MethodType.compareParameters(lhs: invocation, rhs: method.method, matcher: matcher) + }, + name: assertionName + ) + }() + MockyAssert(success, "Expected: \(count) invocations of `\(assertionName)`, but was: \(fullMatches).\(feedback)", file: file, line: line) + } + + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } + } + private func methodReturnValue(_ method: MethodType) throws -> StubProduct { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let candidates = sequencingPolicy.sorted(methodReturnValues, by: { $0.method.intValue() > $1.method.intValue() }) + let matched = candidates.first(where: { $0.isValid && MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch }) + guard let product = matched?.getProduct(policy: self.stubbingPolicy) else { throw MockError.notStubed } + return product + } + private func methodPerformValue(_ method: MethodType) -> Any? { + matcher.set(file: self.file, line: self.line) + defer { matcher.clearFileAndLine() } + let matched = methodPerformValues.reversed().first { MethodType.compareParameters(lhs: $0.method, rhs: method, matcher: matcher).isFullMatch } + return matched?.performs + } + private func matchingCalls(_ method: MethodType, file: StaticString?, line: UInt?) -> [MethodType] { + matcher.set(file: file ?? self.file, line: line ?? self.line) + defer { matcher.clearFileAndLine() } + return invocations.filter { MethodType.compareParameters(lhs: $0, rhs: method, matcher: matcher).isFullMatch } + } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count + } + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } + } + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } + } + private func onFatalFailure(_ message: String) { + guard let file = self.file, let line = self.line else { return } // Let if fail if cannot handle gratefully + SwiftyMockyTestObserver.handleFatalError(message: message, file: file, line: line) + } +} + From cf0b855e64570656cdf6fb2466ba2dc567a6741d Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Thu, 21 Mar 2024 10:23:33 +0100 Subject: [PATCH 082/136] Discussions UI improvements (#346) * style: fixed vertical paddings * chore: added unnamed category String --- .../Discussion/Domain/Model/DiscussionTopic.swift | 2 +- .../Comments/Base/ParentCommentView.swift | 3 +-- .../Comments/Responses/ResponsesView.swift | 2 +- .../Presentation/Comments/Thread/ThreadView.swift | 8 ++++---- .../DiscussionTopics/DiscussionTopicsView.swift | 11 ++++++----- .../DiscussionTopics/DiscussionTopicsViewModel.swift | 3 ++- .../Discussion/Presentation/Posts/PostsView.swift | 4 +++- Discussion/Discussion/SwiftGen/Strings.swift | 2 ++ Discussion/Discussion/en.lproj/Localizable.strings | 1 + Discussion/Discussion/uk.lproj/Localizable.strings | 1 + 10 files changed, 22 insertions(+), 15 deletions(-) diff --git a/Discussion/Discussion/Domain/Model/DiscussionTopic.swift b/Discussion/Discussion/Domain/Model/DiscussionTopic.swift index 536d48aca..ac1ab5996 100644 --- a/Discussion/Discussion/Domain/Model/DiscussionTopic.swift +++ b/Discussion/Discussion/Domain/Model/DiscussionTopic.swift @@ -44,7 +44,7 @@ public struct CoursewareTopics: Hashable, Identifiable { public init(id: String, name: String, threadListURL: String, children: [CoursewareTopics]) { self.id = id - self.name = name + self.name = name.isEmpty ? DiscussionLocalization.Topics.unnamed : name self.threadListURL = threadListURL self.children = children } diff --git a/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift b/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift index 821e8a342..23aae3d52 100644 --- a/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift +++ b/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift @@ -73,7 +73,7 @@ public struct ParentCommentView: View { ? Theme.Colors.accentColor : Theme.Colors.textSecondaryLight) } - }.padding(.top, 31) + }.padding(.top, 15) Text(comments.postTitle) .font(Theme.Fonts.titleLarge) .foregroundColor(Theme.Colors.textPrimary) @@ -133,7 +133,6 @@ public struct ParentCommentView: View { ? Theme.Colors.alert : Theme.Colors.textSecondaryLight) .font(Theme.Fonts.labelLarge) - .padding(.top, 8) } .padding(.horizontal, 24) if isThread { diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index 34069c0b4..f364e0a32 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -88,7 +88,7 @@ public struct ResponsesView: View { Text(DiscussionLocalization.commentsCount(viewModel.itemsCount)) Spacer() } - .padding(.top, 40) + .padding(.top, 20) .padding(.bottom, 14) .padding(.leading, 24) .font(Theme.Fonts.titleMedium) diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index b8bf0167b..4b2374812 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -87,8 +87,7 @@ public struct ThreadView: View { Text(DiscussionLocalization.responsesCount(viewModel.itemsCount)) Spacer() } - .padding(.top, 40) - .padding(.bottom, 14) + .padding(.top, 20) .padding(.leading, 24) .font(Theme.Fonts.titleMedium) .foregroundColor(Theme.Colors.textPrimary) @@ -99,7 +98,8 @@ public struct ThreadView: View { addCommentAvailable: true, onAvatarTap: { username in viewModel.router.showUserDetails(username: username) - }, onLikeTap: { + }, + onLikeTap: { Task { await viewModel.vote( id: comment.commentID, @@ -123,7 +123,7 @@ public struct ThreadView: View { viewModel.router.showComments( commentID: comment.commentID, parentComment: comment, - threadStateSubject: viewModel.threadStateSubject, + threadStateSubject: viewModel.threadStateSubject, animated: true ) }, diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift index c5d84f55c..cee583ec5 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift @@ -51,7 +51,7 @@ public struct DiscussionTopicsView: View { viewModel.router.showDiscussionsSearch(courseID: courseID) } .padding(.horizontal, 24) - .padding(.bottom, 20) + .padding(.top, 10) .accessibilityElement(children: .ignore) .accessibilityLabel(DiscussionLocalization.Topics.search) @@ -68,7 +68,7 @@ public struct DiscussionTopicsView: View { .font(Theme.Fonts.titleMedium) .foregroundColor(Theme.Colors.textSecondary) .padding(.horizontal, 24) - .padding(.top, 40) + .padding(.top, 10) Spacer() } HStack(spacing: 8) { @@ -109,7 +109,7 @@ public struct DiscussionTopicsView: View { .padding(.leading, -20) } - }.padding(.bottom, 26) + }.padding(.bottom, 16) ForEach(Array(topics.enumerated()), id: \.offset) { _, topic in if topic.name != DiscussionLocalization.Topics.allPosts && topic.name != DiscussionLocalization.Topics.postImFollowing { @@ -120,13 +120,13 @@ public struct DiscussionTopicsView: View { .font(Theme.Fonts.titleMedium) .foregroundColor(Theme.Colors.textSecondary) Spacer() - }.padding(.top, 32) + }.padding(.top, 12) .padding(.bottom, 8) .padding(.horizontal, 24) } else { VStack { TopicCell(topic: topic) - .padding(.vertical, 24) + .padding(.vertical, 10) Divider() }.padding(.horizontal, 24) } @@ -210,6 +210,7 @@ public struct TopicCell: View { Text(topic.name) .font(Theme.Fonts.titleMedium) .foregroundColor(Theme.Colors.textPrimary) + .multilineTextAlignment(.leading) Spacer() Image(systemName: "chevron.right") .foregroundColor(Theme.Colors.accentColor) diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift index 9aa950084..90d93ae0c 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift @@ -62,7 +62,8 @@ public class DiscussionTopicsViewModel: ObservableObject { style: .basic ), DiscussionTopic( - name: DiscussionLocalization.Topics.postImFollowing, action: { + name: DiscussionLocalization.Topics.postImFollowing, + action: { self.analytics.discussionFollowingClicked(courseId: self.courseID, courseName: self.title) self.router.showThreads( diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index f599b111d..7f5e14bad 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -146,7 +146,9 @@ public struct PostsView: View { .padding(.horizontal, 24) ForEach(posts, id: \.offset) { index, post in - PostCell(post: post).padding(24) + PostCell(post: post) + .padding(.horizontal, 24) + .padding(.vertical, 10) .id(UUID()) .onAppear { Task { diff --git a/Discussion/Discussion/SwiftGen/Strings.swift b/Discussion/Discussion/SwiftGen/Strings.swift index 1ad256ce1..8711ff868 100644 --- a/Discussion/Discussion/SwiftGen/Strings.swift +++ b/Discussion/Discussion/SwiftGen/Strings.swift @@ -142,6 +142,8 @@ public enum DiscussionLocalization { public static let postImFollowing = DiscussionLocalization.tr("Localizable", "TOPICS.POST_IM_FOLLOWING", fallback: "Posts I'm following") /// Search all posts public static let search = DiscussionLocalization.tr("Localizable", "TOPICS.SEARCH", fallback: "Search all posts") + /// Unnamed subcategory + public static let unnamed = DiscussionLocalization.tr("Localizable", "TOPICS.UNNAMED", fallback: "Unnamed subcategory") } } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length diff --git a/Discussion/Discussion/en.lproj/Localizable.strings b/Discussion/Discussion/en.lproj/Localizable.strings index 553b74123..beca3f98e 100644 --- a/Discussion/Discussion/en.lproj/Localizable.strings +++ b/Discussion/Discussion/en.lproj/Localizable.strings @@ -12,6 +12,7 @@ "TOPICS.ALL_POSTS" = "All Posts"; "TOPICS.POST_IM_FOLLOWING" = "Posts I'm following"; "TOPICS.MAIN_CATEGORIES" = "Main categories"; +"TOPICS.UNNAMED" = "Unnamed subcategory"; "POSTS.SORT.RECENT_ACTIVITY" = "Recent Activity"; "POSTS.SORT.MOST_ACTIVITY" = "Most Activity"; diff --git a/Discussion/Discussion/uk.lproj/Localizable.strings b/Discussion/Discussion/uk.lproj/Localizable.strings index f54f4796f..046e63bfd 100644 --- a/Discussion/Discussion/uk.lproj/Localizable.strings +++ b/Discussion/Discussion/uk.lproj/Localizable.strings @@ -12,6 +12,7 @@ "TOPICS.ALL_POSTS" = "Всі пости"; "TOPICS.POST_IM_FOLLOWING" = "Улюблені пости"; "TOPICS.MAIN_CATEGORIES" = "Основні категорії"; +"TOPICS.UNNAMED" = "Unnamed subcategory"; "POSTS.SORT.RECENT_ACTIVITY" = "Остання активність"; "POSTS.SORT.MOST_ACTIVITY" = "Найактивниші"; From 75bba402fb2776b5e249bd7c61f35d725944eb5c Mon Sep 17 00:00:00 2001 From: Vadim Kuznetsov Date: Thu, 21 Mar 2024 12:23:46 +0300 Subject: [PATCH 083/136] fix: UI issue with top margin on content page (#341) [iOS] UI issue with top margin on content page on iPad #335 --- .../Presentation/Video/EncodedVideoPlayer.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift index 485eb5c17..d13af950e 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift @@ -77,7 +77,6 @@ public struct EncodedVideoPlayer: View { }, seconds: { seconds in currentTime = seconds }) - .statusBarHidden(false) .aspectRatio(16 / 9, contentMode: .fit) .frame(minWidth: isHorizontal ? reader.size.width * 0.6 : 380) .cornerRadius(12) @@ -125,10 +124,14 @@ public struct EncodedVideoPlayer: View { } } } - }.padding(.horizontal, isHorizontal ? 0 : 8) - .onDisappear { - viewModel.controller.player?.allowsExternalPlayback = false - } + } + .padding(.horizontal, isHorizontal ? 0 : 8) + .onDisappear { + viewModel.controller.player?.allowsExternalPlayback = false + } + .onAppear { + viewModel.controller.setNeedsStatusBarAppearanceUpdate() + } } private func pauseScrolling() { From baced2cfc93d75f3c0c51c9bcd1cbb7f6d9ff241 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Thu, 21 Mar 2024 12:24:17 +0100 Subject: [PATCH 084/136] fix: fixed double loading indicator --- Core/Core/View/Base/ProgressBar.swift | 4 +--- .../Presentation/CreateNewThread/CreateNewThreadView.swift | 6 +++--- .../CreateNewThread/CreateNewThreadViewModel.swift | 1 + Discussion/Discussion/Presentation/Posts/PostsView.swift | 1 - 4 files changed, 5 insertions(+), 7 deletions(-) diff --git a/Core/Core/View/Base/ProgressBar.swift b/Core/Core/View/Base/ProgressBar.swift index cf1c12802..7bc6e5195 100644 --- a/Core/Core/View/Base/ProgressBar.swift +++ b/Core/Core/View/Base/ProgressBar.swift @@ -50,9 +50,7 @@ public struct ProgressBar: View { .animation(animation, value: isAnimating) } .onAppear { - withAnimation { - isAnimating = true - } + isAnimating = true } } } diff --git a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift index 6fe53baea..f9c2ae8c9 100644 --- a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift +++ b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift @@ -34,9 +34,6 @@ public struct CreateNewThreadView: View { self.onPostCreated = onPostCreated self.courseID = courseID viewModel.selectedTopic = selectedTopic - Task { - await viewModel.getTopics(courseID: courseID) - } } public var body: some View { @@ -197,6 +194,9 @@ public struct CreateNewThreadView: View { Theme.Colors.background .ignoresSafeArea() ) + .task { + await viewModel.getTopics(courseID: courseID) + } } } diff --git a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadViewModel.swift b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadViewModel.swift index 46d12d614..0fc051b23 100644 --- a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadViewModel.swift +++ b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadViewModel.swift @@ -40,6 +40,7 @@ public class CreateNewThreadViewModel: ObservableObject { @MainActor public func getTopics(courseID: String) async { + guard allTopics.isEmpty else { return } isShowProgress = true do { topics = try await interactor.getTopics(courseID: courseID) diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index f599b111d..aa30faaf5 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -13,7 +13,6 @@ import Theme public struct PostsView: View { @ObservedObject private var viewModel: PostsViewModel - @State private var isShowProgress: Bool = true @State private var showingAlert = false private let router: DiscussionRouter private let title: String From 2de1bc50340d2a4fa8ac9da403b01b4ec4487438 Mon Sep 17 00:00:00 2001 From: Vadim Kuznetsov Date: Thu, 21 Mar 2024 17:47:12 +0300 Subject: [PATCH 085/136] feat: Problem with course menu position on iPad and in Landscape mode on iPhone (#292) * fix: iPad register and login buttons * fix: IPad sign up view * fix: iPad Course outline view * feat: added IPAD_STRETCH config parameter * fix: size bug on small device * fix: iPad stretch for content types * fix: player on iPad and small devices * fix: stretch for search bar on discussions page * fix: discussion search bar stretch * fix: bug of player on small devices * fix: removed stretching for sign up/sign in views * feat: removed feature flag * feat: added readable content size * fix: added readable content size to startup screen * fix: sign in, sign up, reset password readable paddings * fix: scroll bar position for edit profile view * fix: social buttons * feat: added readability and accessibility injections * fix: resize of content and added readability for content types * fix: readability width calculation * feat: added Discussons paddings * feat: added iPad paddings for Dates tab * feat: added injection to html webview * feat: added readable for profile * feat: added padding for tab menu * feat: added paddings to dashboard view * fix: merge conflict * feat: moved filter buttons * feat: added paddings to Delete Account View * feat: added paddings for native discovery view * feat: added padding for CourseVerticalsView * feat: added paddings for responses view * feat: added paddings to UserProfileView * fix: do not block scroll from side for UserProfileView * chore: review's changes * chore: merge conflicts * chore: merge conflict * chore: warning * fix: merge conflicts * chore: merge conflict * chore: removed extra horizontal padding * chore: refactor * chore: label alignment * chore: merge conflicts * chore: status bar fix * chore: merge conflict --- .../Presentation/Login/SignInView.swift | 205 +++++----- .../Registration/SignUpView.swift | 2 +- .../Reset Password/ResetPasswordView.swift | 259 ++++++------- .../SocialAuth/SocialAuthView.swift | 16 +- .../Presentation/Startup/StartupView.swift | 1 + Core/Core.xcodeproj/project.pbxproj | 12 + .../ViewModifiers/ReadabilityModifier.swift | 43 +++ Core/Core/Extensions/ViewExtension.swift | 16 +- .../View/Base/FlexibleKeyboardInputView.swift | 1 + .../View/Base/LogistrationBottomView.swift | 1 - .../ScrollSlidingTabBar.swift | 40 +- .../View/Base/VideoDownloadQualityView.swift | 96 ++--- Core/Core/View/Base/WebBrowser.swift | 2 +- .../Models/AccessibilityInjection.swift | 44 +++ .../Webview/Models/ReadabilityInjection.swift | 82 ++++ .../Webview/Models/WebviewInjection.swift | 10 + Core/Core/View/Base/Webview/WebView.swift | 99 +++-- Core/Core/View/Base/Webview/WebViewHTML.swift | 62 ++- .../Container/CourseContainerView.swift | 15 +- .../Presentation/Dates/CourseDatesView.swift | 91 ++--- .../Handouts/HandoutsUpdatesDetailView.swift | 40 +- .../Presentation/Handouts/HandoutsView.swift | 77 ++-- .../Outline/CourseOutlineView.swift | 8 +- .../CourseVertical/CourseVerticalView.swift | 11 +- .../Presentation/Unit/CourseUnitView.swift | 18 +- .../Unit/CourseUnitViewModel.swift | 2 +- .../CourseUnitVerticalsDropdownView.swift | 1 + .../Video/EncodedVideoPlayer.swift | 21 +- .../Presentation/DashboardView.swift | 172 +++++---- .../NativeDiscovery/CourseDetailsView.swift | 9 +- .../NativeDiscovery/DiscoveryView.swift | 261 ++++++------- .../NativeDiscovery/SearchView.swift | 242 ++++++------ .../Comments/Responses/ResponsesView.swift | 297 +++++++------- .../Comments/Thread/ThreadView.swift | 358 ++++++++--------- .../CreateNewThread/CreateNewThreadView.swift | 294 +++++++------- .../DiscussionSearchTopicsView.swift | 231 +++++------ .../DiscussionSearchTopicsViewModel.swift | 2 +- .../DiscussionTopicsView.swift | 253 ++++++------ .../Presentation/Posts/PostsView.swift | 308 +++++++-------- OpenEdX/DI/ScreenAssembly.swift | 2 +- OpenEdX/View/MainScreenView.swift | 2 +- .../DeleteAccount/DeleteAccountView.swift | 240 ++++++------ .../EditProfile/EditProfileView.swift | 362 +++++++++--------- .../Presentation/Profile/ProfileView.swift | 130 ++++--- .../Profile/UserProfile/UserProfileView.swift | 139 +++---- .../Presentation/Settings/SettingsView.swift | 171 +++++---- .../Settings/VideoQualityView.swift | 119 +++--- 47 files changed, 2620 insertions(+), 2247 deletions(-) create mode 100644 Core/Core/AvoidingHelpers/ViewModifiers/ReadabilityModifier.swift create mode 100644 Core/Core/View/Base/Webview/Models/AccessibilityInjection.swift create mode 100644 Core/Core/View/Base/Webview/Models/ReadabilityInjection.swift diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index 800c50387..c3a45ab15 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -56,117 +56,120 @@ public struct SignInView: View { .padding(.bottom, isHorizontal ? 10 : 40) .accessibilityIdentifier("logo_image") - ScrollView { - VStack { - VStack(alignment: .leading) { - Text(AuthLocalization.SignIn.logInTitle) - .font(Theme.Fonts.displaySmall) - .foregroundColor(Theme.Colors.textPrimary) - .padding(.bottom, 4) - .accessibilityIdentifier("signin_text") - Text(AuthLocalization.SignIn.welcomeBack) - .font(Theme.Fonts.titleSmall) - .foregroundColor(Theme.Colors.textPrimary) - .padding(.bottom, 20) - .accessibilityIdentifier("welcome_back_text") - - Text(AuthLocalization.SignIn.emailOrUsername) - .font(Theme.Fonts.labelLarge) - .foregroundColor(Theme.Colors.textPrimary) - .accessibilityIdentifier("username_text") - TextField(AuthLocalization.SignIn.emailOrUsername, text: $email) - .font(Theme.Fonts.bodyLarge) - .foregroundColor(Theme.Colors.textPrimary) - .keyboardType(.emailAddress) - .textContentType(.emailAddress) - .autocapitalization(.none) - .autocorrectionDisabled() - .padding(.all, 14) - .background( - Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputBackground) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill(Theme.Colors.textInputStroke) - ) - .accessibilityIdentifier("username_textfield") - - Text(AuthLocalization.SignIn.password) - .font(Theme.Fonts.labelLarge) - .foregroundColor(Theme.Colors.textPrimary) - .padding(.top, 18) - .accessibilityIdentifier("password_text") - SecureField(AuthLocalization.SignIn.password, text: $password) - .font(Theme.Fonts.bodyLarge) - .foregroundColor(Theme.Colors.textPrimary) - .padding(.all, 14) - .background( - Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputBackground) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill(Theme.Colors.textInputStroke) - ) - .accessibilityIdentifier("password_textfield") - HStack { - if !viewModel.config.features.startupScreenEnabled { - Button(CoreLocalization.SignIn.registerBtn) { - viewModel.router.showRegisterScreen(sourceScreen: viewModel.sourceScreen) + GeometryReader { proxy in + ScrollView { + VStack { + VStack(alignment: .leading) { + Text(AuthLocalization.SignIn.logInTitle) + .font(Theme.Fonts.displaySmall) + .foregroundColor(Theme.Colors.textPrimary) + .padding(.bottom, 4) + .accessibilityIdentifier("signin_text") + Text(AuthLocalization.SignIn.welcomeBack) + .font(Theme.Fonts.titleSmall) + .foregroundColor(Theme.Colors.textPrimary) + .padding(.bottom, 20) + .accessibilityIdentifier("welcome_back_text") + + Text(AuthLocalization.SignIn.emailOrUsername) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("username_text") + TextField(AuthLocalization.SignIn.emailOrUsername, text: $email) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textPrimary) + .keyboardType(.emailAddress) + .textContentType(.emailAddress) + .autocapitalization(.none) + .autocorrectionDisabled() + .padding(.all, 14) + .background( + Theme.Shapes.textInputShape + .fill(Theme.Colors.textInputBackground) + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(Theme.Colors.textInputStroke) + ) + .accessibilityIdentifier("username_textfield") + + Text(AuthLocalization.SignIn.password) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + .padding(.top, 18) + .accessibilityIdentifier("password_text") + SecureField(AuthLocalization.SignIn.password, text: $password) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textPrimary) + .padding(.all, 14) + .background( + Theme.Shapes.textInputShape + .fill(Theme.Colors.textInputBackground) + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(Theme.Colors.textInputStroke) + ) + .accessibilityIdentifier("password_textfield") + HStack { + if !viewModel.config.features.startupScreenEnabled { + Button(CoreLocalization.SignIn.registerBtn) { + viewModel.router.showRegisterScreen(sourceScreen: viewModel.sourceScreen) + } + .foregroundColor(Theme.Colors.accentColor) + .accessibilityIdentifier("register_button") + + Spacer() } - .foregroundColor(Theme.Colors.accentColor) - .accessibilityIdentifier("register_button") - Spacer() + Button(AuthLocalization.SignIn.forgotPassBtn) { + viewModel.trackForgotPasswordClicked() + viewModel.router.showForgotPasswordScreen() + } + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.accentXColor) + .padding(.top, 0) + .accessibilityIdentifier("forgot_password_button") } - - Button(AuthLocalization.SignIn.forgotPassBtn) { - viewModel.trackForgotPasswordClicked() - viewModel.router.showForgotPasswordScreen() + + if viewModel.isShowProgress { + HStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(20) + .accessibilityIdentifier("progressbar") + }.frame(maxWidth: .infinity) + } else { + StyledButton(CoreLocalization.SignIn.logInBtn) { + Task { + await viewModel.login(username: email, password: password) + } + } + .frame(maxWidth: .infinity) + .padding(.top, 40) + .accessibilityIdentifier("signin_button") } - .font(Theme.Fonts.bodyLarge) - .foregroundColor(Theme.Colors.accentXColor) - .padding(.top, 0) - .accessibilityIdentifier("forgot_password_button") } - - if viewModel.isShowProgress { - HStack(alignment: .center) { - ProgressBar(size: 40, lineWidth: 8) - .padding(20) - .accessibilityIdentifier("progressbar") - }.frame(maxWidth: .infinity) - } else { - StyledButton(CoreLocalization.SignIn.logInBtn) { - Task { - await viewModel.login(username: email, password: password) + if viewModel.socialAuthEnabled { + SocialAuthView( + viewModel: .init( + config: viewModel.config + ) { result in + Task { await viewModel.login(with: result) } } - } - .frame(maxWidth: .infinity) - .padding(.top, 40) - .accessibilityIdentifier("signin_button") + ) } + agreements + Spacer() } - if viewModel.socialAuthEnabled { - SocialAuthView( - viewModel: .init( - config: viewModel.config - ) { result in - Task { await viewModel.login(with: result) } - } - ) - } - agreements - Spacer() + .padding(.horizontal, 24) + .padding(.top, 50) + .frameLimit(width: proxy.size.width) } - .padding(.horizontal, 24) - .padding(.top, 50) - }.roundedBackground(Theme.Colors.loginBackground) + .roundedBackground(Theme.Colors.loginBackground) .scrollAvoidKeyboard(dismissKeyboardByTap: true) - + } } // MARK: - Alert diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index aa2a089ea..6dec70a19 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -163,7 +163,7 @@ public struct SignUpView: View { } .padding(.horizontal, 24) .padding(.top, 24) - + .frameLimit(width: proxy.size.width) } .roundedBackground(Theme.Colors.background) .onRightSwipeGesture { diff --git a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift index 9c6a78823..256b60f97 100644 --- a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift +++ b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift @@ -25,153 +25,156 @@ public struct ResetPasswordView: View { } public var body: some View { - ZStack(alignment: .top) { - VStack { - ThemeAssets.authBackground.swiftUIImage - .resizable() - .edgesIgnoringSafeArea(.top) - } - .frame(maxWidth: .infinity, maxHeight: 200) - .accessibilityIdentifier("auth_bg_image") - - VStack(alignment: .center) { - NavigationBar(title: AuthLocalization.Forgot.title, - titleColor: Theme.Colors.loginNavigationText, - leftButtonColor: Theme.Colors.loginNavigationText, - leftButtonAction: { - viewModel.router.back() - }).padding(.leading, isHorizontal ? 48 : 0) + GeometryReader { proxy in + ZStack(alignment: .top) { + VStack { + ThemeAssets.authBackground.swiftUIImage + .resizable() + .edgesIgnoringSafeArea(.top) + } + .frame(maxWidth: .infinity, maxHeight: 200) + .accessibilityIdentifier("auth_bg_image") - ScrollView { - VStack { - if isRecovered { - ZStack { - VStack { - CoreAssets.checkEmail.swiftUIImage - .resizable() - .frame(width: 100, height: 100) - .padding(.bottom, 40) - .padding(.top, 100) - .accessibilityIdentifier("check_email_image") - - Text(AuthLocalization.Forgot.checkTitle) - .font(Theme.Fonts.titleLarge) - .multilineTextAlignment(.center) + VStack(alignment: .center) { + NavigationBar(title: AuthLocalization.Forgot.title, + titleColor: Theme.Colors.loginNavigationText, + leftButtonColor: Theme.Colors.loginNavigationText, + leftButtonAction: { + viewModel.router.back() + }).padding(.leading, isHorizontal ? 48 : 0) + + ScrollView { + VStack { + if isRecovered { + ZStack { + VStack { + CoreAssets.checkEmail.swiftUIImage + .resizable() + .frame(width: 100, height: 100) + .padding(.bottom, 40) + .padding(.top, 100) + .accessibilityIdentifier("check_email_image") + + Text(AuthLocalization.Forgot.checkTitle) + .font(Theme.Fonts.titleLarge) + .multilineTextAlignment(.center) + .foregroundColor(Theme.Colors.textPrimary) + .padding(.bottom, 4) + .accessibilityIdentifier("recover_title_text") + Text(AuthLocalization.Forgot.checkDescription + email) + .font(Theme.Fonts.bodyMedium) + .multilineTextAlignment(.center) + .foregroundColor(Theme.Colors.textPrimary) + .padding(.bottom, 20) + .accessibilityIdentifier("recover_description_text") + StyledButton(CoreLocalization.SignIn.logInBtn) { + viewModel.router.backToRoot(animated: true) + } + .padding(.top, 30) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("signin_button") + } + } + + } else { + VStack(alignment: .leading) { + Text(AuthLocalization.Forgot.title) + .font(Theme.Fonts.displaySmall) .foregroundColor(Theme.Colors.textPrimary) .padding(.bottom, 4) - .accessibilityIdentifier("recover_title_text") - Text(AuthLocalization.Forgot.checkDescription + email) - .font(Theme.Fonts.bodyMedium) - .multilineTextAlignment(.center) + .accessibilityIdentifier("forgot_title_text") + Text(AuthLocalization.Forgot.description) + .font(Theme.Fonts.titleSmall) .foregroundColor(Theme.Colors.textPrimary) .padding(.bottom, 20) - .accessibilityIdentifier("recover_description_text") - StyledButton(CoreLocalization.SignIn.logInBtn) { - viewModel.router.backToRoot(animated: true) - } - .padding(.top, 30) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("signin_button") - } - } - - } else { - VStack(alignment: .leading) { - Text(AuthLocalization.Forgot.title) - .font(Theme.Fonts.displaySmall) - .foregroundColor(Theme.Colors.textPrimary) - .padding(.bottom, 4) - .accessibilityIdentifier("forgot_title_text") - Text(AuthLocalization.Forgot.description) - .font(Theme.Fonts.titleSmall) - .foregroundColor(Theme.Colors.textPrimary) - .padding(.bottom, 20) - .accessibilityIdentifier("forgot_description_text") - Text(AuthLocalization.SignIn.email) - .font(Theme.Fonts.labelLarge) - .foregroundColor(Theme.Colors.textPrimary) - .accessibilityIdentifier("email_text") - TextField(AuthLocalization.SignIn.email, text: $email) - .font(Theme.Fonts.bodyLarge) - .foregroundColor(Theme.Colors.textPrimary) - .keyboardType(.emailAddress) - .textContentType(.emailAddress) - .autocapitalization(.none) - .autocorrectionDisabled() - .padding(.all, 14) - .background( - Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputBackground) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill(Theme.Colors.textInputStroke) - ) - .accessibilityIdentifier("email_textfield") - if viewModel.isShowProgress { - HStack(alignment: .center) { - ProgressBar(size: 40, lineWidth: 8) - .padding(20) - .accessibilityIdentifier("progressbar") - }.frame(maxWidth: .infinity) - } else { - StyledButton(AuthLocalization.Forgot.request) { - Task { - await viewModel.resetPassword(email: email, isRecovered: $isRecovered) + .accessibilityIdentifier("forgot_description_text") + Text(AuthLocalization.SignIn.email) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("email_text") + TextField(AuthLocalization.SignIn.email, text: $email) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textPrimary) + .keyboardType(.emailAddress) + .textContentType(.emailAddress) + .autocapitalization(.none) + .autocorrectionDisabled() + .padding(.all, 14) + .background( + Theme.Shapes.textInputShape + .fill(Theme.Colors.textInputBackground) + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(Theme.Colors.textInputStroke) + ) + .accessibilityIdentifier("email_textfield") + if viewModel.isShowProgress { + HStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(20) + .accessibilityIdentifier("progressbar") + }.frame(maxWidth: .infinity) + } else { + StyledButton(AuthLocalization.Forgot.request) { + Task { + await viewModel.resetPassword(email: email, isRecovered: $isRecovered) + } } + .padding(.top, 30) + .frame(maxWidth: .infinity) + .accessibilityIdentifier("reset_password_button") } - .padding(.top, 30) - .frame(maxWidth: .infinity) - .accessibilityIdentifier("reset_password_button") } } } - } - .padding(.horizontal, 24) - .padding(.top, 50) - }.roundedBackground(Theme.Colors.background) - .scrollAvoidKeyboard(dismissKeyboardByTap: true) - - } - - // MARK: - Alert - if viewModel.showAlert { - VStack { - Text(viewModel.alertMessage ?? "") - .shadowCardStyle(bgColor: Theme.Colors.accentColor, - textColor: Theme.Colors.white) - .padding(.top, 80) - .accessibilityIdentifier("show_alert_text") - Spacer() + .padding(.horizontal, 24) + .padding(.top, 50) + .frameLimit(width: proxy.size.width) + }.roundedBackground(Theme.Colors.background) + .scrollAvoidKeyboard(dismissKeyboardByTap: true) } - .transition(.move(edge: .top)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.alertMessage = nil + + // MARK: - Alert + if viewModel.showAlert { + VStack { + Text(viewModel.alertMessage ?? "") + .shadowCardStyle(bgColor: Theme.Colors.accentColor, + textColor: Theme.Colors.white) + .padding(.top, 80) + .accessibilityIdentifier("show_alert_text") + Spacer() + } - } - } - - // MARK: - Show error - if viewModel.showError { - VStack { - Spacer() - SnackBarView(message: viewModel.errorMessage) - }.transition(.move(edge: .bottom)) + .transition(.move(edge: .top)) .onAppear { doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.errorMessage = nil + viewModel.alertMessage = nil } } + } + + // MARK: - Show error + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) + }.transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } + } + } } + .ignoresSafeArea(.all, edges: .horizontal) + + .background(Theme.Colors.background.ignoresSafeArea(.all)) + + .hideNavigationBar() } - .ignoresSafeArea(.all, edges: .horizontal) - - .background(Theme.Colors.background.ignoresSafeArea(.all)) - - .hideNavigationBar() } } diff --git a/Authorization/Authorization/Presentation/SocialAuth/SocialAuthView.swift b/Authorization/Authorization/Presentation/SocialAuth/SocialAuthView.swift index 16367414e..57dc84943 100644 --- a/Authorization/Authorization/Presentation/SocialAuth/SocialAuthView.swift +++ b/Authorization/Authorization/Presentation/SocialAuth/SocialAuthView.swift @@ -12,8 +12,8 @@ import Theme struct SocialAuthView: View { // MARK: - Properties - @StateObject var viewModel: SocialAuthViewModel + let iPadButtonWidth: CGFloat = 260 init( authType: SocialAuthType = .signIn, @@ -37,7 +37,17 @@ struct SocialAuthView: View { AuthLocalization.registerWith } } + + private var columns: [GridItem] { + if isPad { + return [GridItem(.fixed(iPadButtonWidth)), GridItem(.fixed(iPadButtonWidth))] + } + return [GridItem(.flexible())] + } + private var isPad: Bool { + UIDevice.current.userInterfaceIdiom == .pad + } // MARK: - Views var body: some View { @@ -46,6 +56,7 @@ struct SocialAuthView: View { buttonsView } .padding(.bottom, 20) + .frame(maxWidth: .infinity) } private var headerView: some View { @@ -56,10 +67,11 @@ struct SocialAuthView: View { .accessibilityIdentifier("social_auth_title_text") Spacer() } + .frame(maxWidth: .infinity, minHeight: 42) } private var buttonsView: some View { - Group { + LazyVGrid(columns: columns) { if viewModel.googleEnabled { SocialAuthButton( image: CoreAssets.iconGoogleWhite.swiftUIImage, diff --git a/Authorization/Authorization/Presentation/Startup/StartupView.swift b/Authorization/Authorization/Presentation/Startup/StartupView.swift index 517cb365e..f0ac99841 100644 --- a/Authorization/Authorization/Presentation/Startup/StartupView.swift +++ b/Authorization/Authorization/Presentation/Startup/StartupView.swift @@ -113,6 +113,7 @@ public struct StartupView: View { .onDisappear { searchQuery = "" } + .frameLimit() } .hideNavigationBar() .padding(.all, isHorizontal ? 1 : 0) diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 3da8c0ff2..78e956b3b 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -91,6 +91,9 @@ 064987982B4D69FF0071642A /* CSSInjectionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 064987902B4D69FE0071642A /* CSSInjectionProtocol.swift */; }; 064987992B4D69FF0071642A /* WebViewScriptInjectionProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 064987912B4D69FE0071642A /* WebViewScriptInjectionProtocol.swift */; }; 0649879A2B4D69FF0071642A /* WebViewHTML.swift in Sources */ = {isa = PBXBuildFile; fileRef = 064987922B4D69FE0071642A /* WebViewHTML.swift */; }; + 06619EAA2B8F2936001FAADE /* ReadabilityModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06619EA92B8F2936001FAADE /* ReadabilityModifier.swift */; }; + 06619EAD2B90918B001FAADE /* ReadabilityInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06619EAC2B90918B001FAADE /* ReadabilityInjection.swift */; }; + 06619EAF2B973B25001FAADE /* AccessibilityInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06619EAE2B973B25001FAADE /* AccessibilityInjection.swift */; }; 06BEEA0E2B6A55C500D25A97 /* ColorInversionInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BEEA0D2B6A55C500D25A97 /* ColorInversionInjection.swift */; }; 070019A528F6F17900D5FC78 /* Data_Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = 070019A428F6F17900D5FC78 /* Data_Media.swift */; }; 070019AC28F6FD0100D5FC78 /* CourseDetailBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 070019AB28F6FD0100D5FC78 /* CourseDetailBlock.swift */; }; @@ -264,6 +267,9 @@ 064987902B4D69FE0071642A /* CSSInjectionProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CSSInjectionProtocol.swift; sourceTree = ""; }; 064987912B4D69FE0071642A /* WebViewScriptInjectionProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebViewScriptInjectionProtocol.swift; sourceTree = ""; }; 064987922B4D69FE0071642A /* WebViewHTML.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebViewHTML.swift; sourceTree = ""; }; + 06619EA92B8F2936001FAADE /* ReadabilityModifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadabilityModifier.swift; sourceTree = ""; }; + 06619EAC2B90918B001FAADE /* ReadabilityInjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadabilityInjection.swift; sourceTree = ""; }; + 06619EAE2B973B25001FAADE /* AccessibilityInjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityInjection.swift; sourceTree = ""; }; 06BEEA0D2B6A55C500D25A97 /* ColorInversionInjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorInversionInjection.swift; sourceTree = ""; }; 070019A428F6F17900D5FC78 /* Data_Media.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_Media.swift; sourceTree = ""; }; 070019AB28F6FD0100D5FC78 /* CourseDetailBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDetailBlock.swift; sourceTree = ""; }; @@ -436,6 +442,7 @@ children = ( 027BD3B62909476200392132 /* DismissKeyboardTapViewModifier.swift */, 027BD3B72909476200392132 /* KeyboardAvoidingModifier.swift */, + 06619EA92B8F2936001FAADE /* ReadabilityModifier.swift */, ); path = ViewModifiers; sourceTree = ""; @@ -517,6 +524,8 @@ 0649878C2B4D69FE0071642A /* SurveyCssInjection.swift */, 0649878D2B4D69FE0071642A /* WebviewMessage.swift */, 06BEEA0D2B6A55C500D25A97 /* ColorInversionInjection.swift */, + 06619EAC2B90918B001FAADE /* ReadabilityInjection.swift */, + 06619EAE2B973B25001FAADE /* AccessibilityInjection.swift */, ); path = Models; sourceTree = ""; @@ -1028,6 +1037,7 @@ 02F6EF4A28D9F0A700835477 /* DateExtension.swift in Sources */, DBF6F2462B01DAFE0098414B /* AgreementConfig.swift in Sources */, 027BD3AF2909475000392132 /* DismissKeyboardTapHandler.swift in Sources */, + 06619EAA2B8F2936001FAADE /* ReadabilityModifier.swift in Sources */, BAFB99902B14B377007D09F9 /* GoogleConfig.swift in Sources */, 02E225B0291D29EB0067769A /* UrlExtension.swift in Sources */, 02CF46C829546AA200A698EE /* NoCachedDataError.swift in Sources */, @@ -1040,8 +1050,10 @@ 06BEEA0E2B6A55C500D25A97 /* ColorInversionInjection.swift in Sources */, 064987972B4D69FF0071642A /* WebView.swift in Sources */, 0770DE2A28D0929E006D8A5D /* HTTPTask.swift in Sources */, + 06619EAD2B90918B001FAADE /* ReadabilityInjection.swift in Sources */, 02284C182A3B1AE00007117F /* UIApplicationExtension.swift in Sources */, 0255D5582936283A004DBC1A /* UploadBodyEncoding.swift in Sources */, + 06619EAF2B973B25001FAADE /* AccessibilityInjection.swift in Sources */, BAFB99822B0E2354007D09F9 /* FacebookConfig.swift in Sources */, 027BD3B32909475900392132 /* Publishers+KeyboardState.swift in Sources */, 0727877D28D25212002E9142 /* ProgressBar.swift in Sources */, diff --git a/Core/Core/AvoidingHelpers/ViewModifiers/ReadabilityModifier.swift b/Core/Core/AvoidingHelpers/ViewModifiers/ReadabilityModifier.swift new file mode 100644 index 000000000..b74ad2b3b --- /dev/null +++ b/Core/Core/AvoidingHelpers/ViewModifiers/ReadabilityModifier.swift @@ -0,0 +1,43 @@ +// +// ReadabilityModifier.swift +// Core +// +// Created by Vadim Kuznetsov on 28.02.24. +// +import SwiftUI + +public struct ReadabilityHelper { + static let unitSize: CGFloat = 20 + + public static func padding(containerWidth: CGFloat, unitWidth: CGFloat) -> CGFloat { + let idealWidth = 70 * unitWidth / 2 + + guard containerWidth >= idealWidth else { + return 0 + } + + let padding = round((containerWidth - idealWidth) / 2) + return padding + } +} + +struct ReadabilityModifier: ViewModifier { + @ScaledMetric(relativeTo: .body) private var unit: CGFloat = ReadabilityHelper.unitSize + var width: CGFloat? + + func body(content: Content) -> some View { + if let width = width { + content + .padding(.horizontal, padding(for: width)) + } else { + GeometryReader { geometryProxy in + content + .padding(.horizontal, padding(for: geometryProxy.size.width)) + } + } + } + + private func padding(for width: CGFloat) -> CGFloat { + return ReadabilityHelper.padding(containerWidth: width, unitWidth: unit) + } +} diff --git a/Core/Core/Extensions/ViewExtension.swift b/Core/Core/Extensions/ViewExtension.swift index 1700e774f..446de5321 100644 --- a/Core/Core/Extensions/ViewExtension.swift +++ b/Core/Core/Extensions/ViewExtension.swift @@ -11,7 +11,6 @@ import SwiftUI import Theme public extension View { - func cardStyle( top: CGFloat? = 0, bottom: CGFloat? = 0, @@ -90,15 +89,8 @@ public extension View { .padding(.horizontal, 48) } - @ViewBuilder - func frameLimit(sizePortrait: CGFloat = 560, sizeLandscape: CGFloat = 648) -> some View { - if UIDevice.current.userInterfaceIdiom == .pad { - HStack { - Spacer(minLength: 0) - self.frame(maxWidth: UIDevice.current.orientation.isPortrait ? sizePortrait : sizeLandscape) - Spacer(minLength: 0) - } - } else { self } + func frameLimit(width: CGFloat? = nil) -> some View { + modifier(ReadabilityModifier(width: width)) } @ViewBuilder @@ -152,7 +144,6 @@ public extension View { ipadMaxHeight: CGFloat = .infinity, maxIpadWidth: CGFloat = 420 ) -> some View { - var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } return ZStack { RoundedCorners(tl: 24, tr: 24) .offset(y: 1) @@ -162,8 +153,7 @@ public extension View { .offset(y: 2) .foregroundColor(color) self - .offset(y: 2) - .frame(maxWidth: maxIpadWidth, maxHeight: idiom == .pad ? ipadMaxHeight : .infinity) + .offset(y: 2) } } diff --git a/Core/Core/View/Base/FlexibleKeyboardInputView.swift b/Core/Core/View/Base/FlexibleKeyboardInputView.swift index fc1df6ae3..60dfc5085 100644 --- a/Core/Core/View/Base/FlexibleKeyboardInputView.swift +++ b/Core/Core/View/Base/FlexibleKeyboardInputView.swift @@ -97,6 +97,7 @@ public struct FlexibleKeyboardInputView: View { } .padding(.leading, 6) .padding(.trailing, 14) + .frameLimit() }.frame(maxWidth: .infinity, maxHeight: commentSize + 16) .background( Theme.Colors.commentCellBackground diff --git a/Core/Core/View/Base/LogistrationBottomView.swift b/Core/Core/View/Base/LogistrationBottomView.swift index 5f01cc98a..776ab12d2 100644 --- a/Core/Core/View/Base/LogistrationBottomView.swift +++ b/Core/Core/View/Base/LogistrationBottomView.swift @@ -41,7 +41,6 @@ public struct LogistrationBottomView: View { StyledButton(CoreLocalization.SignIn.registerBtn) { action(.register) } - .frame(maxWidth: .infinity) .accessibilityIdentifier("logistration_register_button") StyledButton( diff --git a/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift index 129a5f6b9..9e79a9dea 100644 --- a/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift +++ b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift @@ -12,7 +12,7 @@ public struct ScrollSlidingTabBar: View { @Binding private var selection: Int @State private var buttonFrames: [Int: CGRect] = [:] - + private let containerWidth: CGFloat private let tabs: [String] private let style: Style private let onTap: ((Int) -> Void)? @@ -25,33 +25,38 @@ public struct ScrollSlidingTabBar: View { selection: Binding, tabs: [String], style: Style = .default, + containerWidth: CGFloat, onTap: ((Int) -> Void)? = nil) { self._selection = selection self.tabs = tabs self.style = style self.onTap = onTap + self.containerWidth = containerWidth } public var body: some View { - ScrollViewReader { proxy in - ScrollView(.horizontal, showsIndicators: false) { - VStack(alignment: .leading, spacing: 0) { - buttons() - - ZStack(alignment: .leading) { - Rectangle() - .fill(style.borderColor) - .frame(height: style.borderHeight, alignment: .leading) - indicatorContainer() + ZStack(alignment: .bottomLeading) { + Rectangle() + .fill(style.borderColor) + .frame(height: style.borderHeight, alignment: .leading) + ScrollViewReader { proxy in + ScrollView(.horizontal, showsIndicators: false) { + VStack(alignment: .leading, spacing: 0) { + buttons() + + ZStack(alignment: .leading) { + indicatorContainer() + } } + .coordinateSpace(name: containerSpace) } - .coordinateSpace(name: containerSpace) - } - .onChange(of: selection) { newValue in - withAnimation { - proxy.scrollTo(newValue, anchor: .center) + .onChange(of: selection) { newValue in + withAnimation { + proxy.scrollTo(newValue, anchor: .center) + } } } + .frameLimit(width: containerWidth) } } @@ -182,7 +187,8 @@ private struct SlidingTabConsumerView: View { VStack(alignment: .leading) { ScrollSlidingTabBar( selection: $selection, - tabs: ["First", "Second", "Third", "Fourth", "Fifth", "Sixth"] + tabs: ["First", "Second", "Third", "Fourth", "Fifth", "Sixth"], + containerWidth: 300 ) TabView(selection: $selection) { HStack { diff --git a/Core/Core/View/Base/VideoDownloadQualityView.swift b/Core/Core/View/Base/VideoDownloadQualityView.swift index 7482a8d0d..2401a4f27 100644 --- a/Core/Core/View/Base/VideoDownloadQualityView.swift +++ b/Core/Core/View/Base/VideoDownloadQualityView.swift @@ -49,59 +49,61 @@ public struct VideoDownloadQualityView: View { } public var body: some View { - ZStack(alignment: .top) { - // MARK: - Page Body - ScrollView { - VStack(alignment: .leading, spacing: 24) { - ForEach(viewModel.downloadQuality, id: \.self) { quality in - Button(action: { - analytics.videoQualityChanged( - .videoDownloadQualityChanged, - bivalue: .videoDownloadQualityChanged, - value: quality.value ?? "", - oldValue: viewModel.selectedDownloadQuality.value ?? "" - ) - - viewModel.selectedDownloadQuality = quality - }, label: { - HStack { - SettingsCell( - title: quality.title, - description: quality.description + GeometryReader { proxy in + ZStack(alignment: .top) { + // MARK: - Page Body + ScrollView { + VStack(alignment: .leading, spacing: 24) { + ForEach(viewModel.downloadQuality, id: \.self) { quality in + Button(action: { + analytics.videoQualityChanged( + .videoDownloadQualityChanged, + bivalue: .videoDownloadQualityChanged, + value: quality.value ?? "", + oldValue: viewModel.selectedDownloadQuality.value ?? "" ) - .accessibilityElement(children: .ignore) - .accessibilityLabel("\(quality.title) \(quality.description ?? "")") - Spacer() - CoreAssets.checkmark.swiftUIImage - .renderingMode(.template) - .foregroundColor(Theme.Colors.accentXColor) - .opacity(quality == viewModel.selectedDownloadQuality ? 1 : 0) - .accessibilityIdentifier("checkmark_image") - } - .foregroundColor(Theme.Colors.textPrimary) - }) - .accessibilityIdentifier("select_quality_button") - Divider() + viewModel.selectedDownloadQuality = quality + }, label: { + HStack { + SettingsCell( + title: quality.title, + description: quality.description + ) + .accessibilityElement(children: .ignore) + .accessibilityLabel("\(quality.title) \(quality.description ?? "")") + Spacer() + CoreAssets.checkmark.swiftUIImage + .renderingMode(.template) + .foregroundColor(Theme.Colors.accentXColor) + .opacity(quality == viewModel.selectedDownloadQuality ? 1 : 0) + .accessibilityIdentifier("checkmark_image") + + } + .foregroundColor(Theme.Colors.textPrimary) + }) + .accessibilityIdentifier("select_quality_button") + Divider() + } } + .frame( + minWidth: 0, + maxWidth: .infinity, + alignment: .topLeading + ) + .padding(.horizontal, 24) + .frameLimit(width: proxy.size.width) } - .frame( - minWidth: 0, - maxWidth: .infinity, - alignment: .topLeading - ) - .padding(.horizontal, 24) + .padding(.top, 8) } - .frameLimit(sizePortrait: 420) - .padding(.top, 8) + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(CoreLocalization.Settings.videoDownloadQualityTitle) + .background( + Theme.Colors.background + .ignoresSafeArea() + ) } - .navigationBarHidden(false) - .navigationBarBackButtonHidden(false) - .navigationTitle(CoreLocalization.Settings.videoDownloadQualityTitle) - .background( - Theme.Colors.background - .ignoresSafeArea() - ) } } diff --git a/Core/Core/View/Base/WebBrowser.swift b/Core/Core/View/Base/WebBrowser.swift index 8f441bacd..9d6a113ac 100644 --- a/Core/Core/View/Base/WebBrowser.swift +++ b/Core/Core/View/Base/WebBrowser.swift @@ -57,7 +57,7 @@ public struct WebBrowser: View { viewModel: .init( url: url, baseURL: "", - injections: [.colorInversionCss] + injections: [.colorInversionCss, .readability, .accessibility] ), isLoading: $isLoading, refreshCookies: {} diff --git a/Core/Core/View/Base/Webview/Models/AccessibilityInjection.swift b/Core/Core/View/Base/Webview/Models/AccessibilityInjection.swift new file mode 100644 index 000000000..908580f01 --- /dev/null +++ b/Core/Core/View/Base/Webview/Models/AccessibilityInjection.swift @@ -0,0 +1,44 @@ +// +// AccessibilityInjection.swift +// Core +// +// Created by Vadim Kuznetsov on 5.03.24. +// + +import Foundation + +import Combine +import WebKit + +public struct AccessibilityInjection: WebViewScriptInjectionProtocol, CSSInjectionProtocol { + public var id: String = "AccessibilityInjection" + public var script: String { + return """ + window.addEventListener("load", () => { + window.webkit.messageHandlers.accessibility.postMessage(""); + }); + window.addEventListener("UIContentSizeCategory.didChangeNotification", () => { + window.webkit.messageHandlers.accessibility.postMessage(""); + }); + """ + } + public var messages: [WebviewMessage]? { + let message = WebviewMessage(name: "accessibility") { _, webview in + guard let webview = webview else { return } + webview.evaluateJavaScript(getScript()) + } + return [message] + } + public var injectionTime: WKUserScriptInjectionTime = .atDocumentStart + public var forMainFrameOnly: Bool = false + + private func getScript() -> String { + let percent = UIFontMetrics(forTextStyle: .body).scaledValue(for: UIFont.systemFontSize) / UIFont.systemFontSize + return """ + function resizeAccessibilityText() { + document.documentElement.style.webkitTextSizeAdjust='\(percent * 100)%'; + } + resizeAccessibilityText(); + """ + } +} diff --git a/Core/Core/View/Base/Webview/Models/ReadabilityInjection.swift b/Core/Core/View/Base/Webview/Models/ReadabilityInjection.swift new file mode 100644 index 000000000..b9d1a895a --- /dev/null +++ b/Core/Core/View/Base/Webview/Models/ReadabilityInjection.swift @@ -0,0 +1,82 @@ +// +// ReadabilityInjection.swift +// Core +// +// Created by Vadim Kuznetsov on 29.02.24. +// + +import WebKit + +public struct ReadabilityInjection: WebViewScriptInjectionProtocol, CSSInjectionProtocol { + public var id: String = "ReadabilityInjection" + public var script: String { + let uniqueId = UUID().uuidString.replacingOccurrences(of: "-", with: "") + return """ + window.addEventListener("load", () => { + window.webkit.messageHandlers.readability.postMessage(""); + + window.resizeObserver\(uniqueId) = new ResizeObserver((entries) => { + window.webkit.messageHandlers.readability.postMessage(""); + }); + + window.resizeObserver\(uniqueId).observe(document.body); + + }); + window.addEventListener("UIContentSizeCategory.didChangeNotification", () => { + window.webkit.messageHandlers.readability.postMessage(""); + }); + """ + } + public var messages: [WebviewMessage]? { + let message = WebviewMessage(name: "readability") { _, webview in + guard let webview = webview else { return } + let contentWidth = webview.frame.size.width + let css = css(for: contentWidth) + webview.evaluateJavaScript(script(for: css)) + } + return [message] + } + public var injectionTime: WKUserScriptInjectionTime = .atDocumentStart + public var forMainFrameOnly: Bool = true + + private func css(for width: CGFloat) -> String { + let unitSize = UIFontMetrics(forTextStyle: .body).scaledValue(for: ReadabilityHelper.unitSize) + let padding = ReadabilityHelper.padding(containerWidth: width, unitWidth: unitSize) + return """ + body { + padding-left: \(padding)px !important; + padding-right: \(padding)px !important; + } + """ + } + + private func script(for css: String) -> String { + """ + function alterReadabilityCss() { + const id = "readabilityCss"; + const style = document.getElementById(id); + const css = `\(css)`; + if (style != undefined) { + style.innerHTML = ""; + if (style.styleSheet) { + style.styleSheet.cssText = css; + } else { + style.appendChild(document.createTextNode(css)); + } + } else { + const head = document.head || document.getElementsByTagName('head')[0], + style = document.createElement('style'); + head.appendChild(style); + style.type = 'text/css'; + style.id = id; + if (style.styleSheet) { + style.styleSheet.cssText = css; + } else { + style.appendChild(document.createTextNode(css)); + } + } + } + alterReadabilityCss(); + """ + } +} diff --git a/Core/Core/View/Base/Webview/Models/WebviewInjection.swift b/Core/Core/View/Base/Webview/Models/WebviewInjection.swift index d4cc71803..da40feaae 100644 --- a/Core/Core/View/Base/Webview/Models/WebviewInjection.swift +++ b/Core/Core/View/Base/Webview/Models/WebviewInjection.swift @@ -56,4 +56,14 @@ public extension WebviewInjection { AjaxInjection() .webviewInjection() } + + static var readability: WebviewInjection { + ReadabilityInjection() + .webviewInjection() + } + + static var accessibility: WebviewInjection { + AccessibilityInjection() + .webviewInjection() + } } diff --git a/Core/Core/View/Base/Webview/WebView.swift b/Core/Core/View/Base/Webview/WebView.swift index df174b904..ce757b1c9 100644 --- a/Core/Core/View/Base/Webview/WebView.swift +++ b/Core/Core/View/Base/Webview/WebView.swift @@ -5,10 +5,11 @@ // Created by  Stepanok Ivan on 07.10.2022. // +import Combine import Foundation -import WebKit import SwiftUI import Theme +import WebKit public protocol WebViewNavigationDelegate: AnyObject { func webView( @@ -52,6 +53,7 @@ public struct WebView: UIViewRepresentable { } public class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler { + var cancellables: [AnyCancellable] = [] var parent: WebView var url: URL? @@ -59,7 +61,7 @@ public struct WebView: UIViewRepresentable { self.parent = parent super.init() - addObserver() + addObservers() } public func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) { @@ -168,34 +170,33 @@ public struct WebView: UIViewRepresentable { return .allow } - private func addObserver() { - NotificationCenter.default.addObserver( - self, - selector: #selector(reload), - name: .webviewReloadNotification, - object: nil - ) + private func addObservers() { + cancellables.removeAll() + NotificationCenter.default.publisher(for: .webviewReloadNotification, object: nil) + .sink { [weak self] _ in + self?.reload() + } + .store(in: &cancellables) + + NotificationCenter.default.publisher(for: UIContentSizeCategory.didChangeNotification, object: nil) + .sink { [weak self] _ in + self?.webview?.evaluateSizeNotification() + } + .store(in: &cancellables) } - + fileprivate var webview: WKWebView? @objc private func reload() { parent.isLoading = true webview?.reload() } - - deinit { - NotificationCenter.default.removeObserver(self) - } public func userContentController( _ userContentController: WKUserContentController, didReceive message: WKScriptMessage ) { - let messages = parent.viewModel.injections?.compactMap({$0.messages}).flatMap({$0}) ?? [] - if let currentMessage = messages.first(where: { $0.name == message.name }) { - currentMessage.handler(message.body, message.webView) - } + parent.viewModel.injections?.handle(message: message) } } @@ -238,19 +239,9 @@ public struct WebView: UIViewRepresentable { webView.scrollView.backgroundColor = Theme.Colors.background.uiColor() webView.scrollView.alwaysBounceVertical = false webView.scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 200, right: 0) - - for injection in viewModel.injections ?? [] { - let script = WKUserScript( - source: injection.script, - injectionTime: injection.injectionTime, - forMainFrameOnly: injection.forMainFrameOnly - ) - webView.configuration.userContentController.addUserScript(script) - - for message in injection.messages ?? [] { - webView.configuration.userContentController.add(context.coordinator, name: message.name) - } - } + // To add ability to change font size with webkitTextSizeAdjust need to set mode to mobile + webView.configuration.defaultWebpagePreferences.preferredContentMode = .mobile + webView.applyInjections(viewModel.injections, toHandler: context.coordinator) webView.customUserAgent = userAgent context.coordinator.url = nil @@ -272,7 +263,49 @@ public struct WebView: UIViewRepresentable { } public static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) { - uiView.configuration.userContentController.removeAllUserScripts() - uiView.configuration.userContentController.removeAllScriptMessageHandlers() + uiView.clear() + } +} + +extension WKWebView { + func evaluateSizeNotification() { + let script = """ + function sendSizeEvent() { + const contentSizeEvent = new CustomEvent("UIContentSizeCategory.didChangeNotification", { bubbles: true }); + window.dispatchEvent(contentSizeEvent); + } + sendSizeEvent(); + """ + evaluateJavaScript(script) + } + + func applyInjections(_ injections: [WebviewInjection]?, toHandler handler: WKScriptMessageHandler) { + for injection in injections ?? [] { + let script = WKUserScript( + source: injection.script, + injectionTime: injection.injectionTime, + forMainFrameOnly: injection.forMainFrameOnly + ) + configuration.userContentController.addUserScript(script) + + for message in injection.messages ?? [] { + configuration.userContentController.add(handler, name: message.name) + } + } + } + + func clear() { + configuration.userContentController.removeAllUserScripts() + configuration.userContentController.removeAllScriptMessageHandlers() + } +} + +extension Array where Element == WebviewInjection { + func handle(message: WKScriptMessage) { + let messages = compactMap{ $0.messages } + .flatMap{ $0 } + if let currentMessage = messages.first(where: { $0.name == message.name }) { + currentMessage.handler(message.body, message.webView) + } } } diff --git a/Core/Core/View/Base/Webview/WebViewHTML.swift b/Core/Core/View/Base/Webview/WebViewHTML.swift index c54e6a29b..4db4d2abb 100644 --- a/Core/Core/View/Base/Webview/WebViewHTML.swift +++ b/Core/Core/View/Base/Webview/WebViewHTML.swift @@ -5,22 +5,21 @@ // Created by  Stepanok Ivan on 06.03.2023. // +import Combine import SwiftUI import WebKit public struct WebViewHtml: UIViewRepresentable { let htmlString: String + let injections: [WebviewInjection]? - public init(_ htmlString: String) { + public init(_ htmlString: String, injections: [WebviewInjection]? = nil) { self.htmlString = htmlString + self.injections = injections } public func makeUIView(context: Context) -> WKWebView { - return WKWebView() - } - - public func updateUIView(_ webView: WKWebView, context: Context) { - webView.loadHTMLString(htmlString, baseURL: nil) + let webView = WKWebView() webView.navigationDelegate = context.coordinator webView.scrollView.bounces = false webView.scrollView.alwaysBounceHorizontal = false @@ -31,13 +30,60 @@ public struct WebViewHtml: UIViewRepresentable { webView.backgroundColor = .clear webView.scrollView.backgroundColor = UIColor.clear webView.scrollView.alwaysBounceVertical = false + // To add ability to change font size with webkitTextSizeAdjust need to set mode to mobile + webView.configuration.defaultWebpagePreferences.preferredContentMode = .mobile + webView.applyInjections(injections, toHandler: context.coordinator) + + context.coordinator.webview = webView + #if DEBUG + if #available(iOS 16.4, *) { + webView.isInspectable = true + } + #endif + return webView + } + + public func updateUIView(_ webView: WKWebView, context: Context) { + webView.loadHTMLString(htmlString, baseURL: nil) } public func makeCoordinator() -> Coordinator { - return Coordinator() + let coordinator = Coordinator(injections: injections) + return coordinator + } + + public static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) { + uiView.clear() } - public class Coordinator: NSObject, WKNavigationDelegate { + public class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler { + weak var webview: WKWebView? + var injections: [WebviewInjection]? + var cancellables: [AnyCancellable] = [] + + init(injections: [WebviewInjection]? = nil) { + self.injections = injections + super.init() + self.addObservers() + } + + func addObservers() { + cancellables.removeAll() + NotificationCenter.default.publisher(for: UIContentSizeCategory.didChangeNotification, object: nil) + .sink { [weak self] _ in + self?.webview?.evaluateSizeNotification() + } + .store(in: &cancellables) + + } + + public func userContentController( + _ userContentController: WKUserContentController, + didReceive message: WKScriptMessage + ) { + injections?.handle(message: message) + } + public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index dd881e0d2..2bd2708fe 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -63,20 +63,23 @@ public struct CourseContainerView: View { dateTabIndex: CourseTab.dates.rawValue ) } else { - VStack(spacing: 0) { - if viewModel.config.uiComponents.courseTopTabBarEnabled { - topTabBar + GeometryReader { proxy in + VStack(spacing: 0) { + if viewModel.config.uiComponents.courseTopTabBarEnabled { + topTabBar(containerWidth: proxy.size.width) + } + tabs } - tabs } } } } - private var topTabBar: some View { + private func topTabBar(containerWidth: CGFloat) -> some View { ScrollSlidingTabBar( selection: $viewModel.selection, - tabs: CourseTab.allCases.map { $0.title } + tabs: CourseTab.allCases.map { $0.title }, + containerWidth: containerWidth ) { newValue in isAnimatingForTap = true viewModel.selection = newValue diff --git a/Course/Course/Presentation/Dates/CourseDatesView.swift b/Course/Course/Presentation/Dates/CourseDatesView.swift index 482cdacd4..491497944 100644 --- a/Course/Course/Presentation/Dates/CourseDatesView.swift +++ b/Course/Course/Presentation/Dates/CourseDatesView.swift @@ -103,58 +103,61 @@ struct CourseDateListView: View { let courseID: String var body: some View { - VStack { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - if !courseDates.hasEnded { - DatesStatusInfoView( - datesBannerInfo: courseDates.datesBannerInfo, - courseID: courseID, - courseDatesViewModel: viewModel, - screen: .courseDates - ) - .padding(.bottom, 16) - } - - ForEach(Array(viewModel.sortedStatuses), id: \.self) { status in - let courseDateBlockDict = courseDates.statusDatesBlocks[status]! - if status == .completed { - CompletedBlocks( - isExpanded: $isExpanded, - courseDateBlockDict: courseDateBlockDict, - viewModel: viewModel + GeometryReader { proxy in + VStack { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if !courseDates.hasEnded { + DatesStatusInfoView( + datesBannerInfo: courseDates.datesBannerInfo, + courseID: courseID, + courseDatesViewModel: viewModel, + screen: .courseDates ) - } else { - Text(status.rawValue) - .font(Theme.Fonts.titleSmall) - .padding(.top, 10) - .padding(.bottom, 10) - HStack { - TimeLineView(status: status) - .padding(.bottom, 15) - VStack(alignment: .leading) { - ForEach(courseDateBlockDict.keys.sorted(), id: \.self) { date in - let blocks = courseDateBlockDict[date]! - let block = blocks[0] - Text(block.formattedDate) - .font(Theme.Fonts.labelMedium) - .foregroundStyle(Theme.Colors.textPrimary) - BlockStatusView( - viewModel: viewModel, - block: block, - blocks: blocks - ) + .padding(.bottom, 16) + } + + ForEach(Array(viewModel.sortedStatuses), id: \.self) { status in + let courseDateBlockDict = courseDates.statusDatesBlocks[status]! + if status == .completed { + CompletedBlocks( + isExpanded: $isExpanded, + courseDateBlockDict: courseDateBlockDict, + viewModel: viewModel + ) + } else { + Text(status.rawValue) + .font(Theme.Fonts.titleSmall) + .padding(.top, 10) + .padding(.bottom, 10) + HStack { + TimeLineView(status: status) + .padding(.bottom, 15) + VStack(alignment: .leading) { + ForEach(courseDateBlockDict.keys.sorted(), id: \.self) { date in + let blocks = courseDateBlockDict[date]! + let block = blocks[0] + Text(block.formattedDate) + .font(Theme.Fonts.labelMedium) + .foregroundStyle(Theme.Colors.textPrimary) + BlockStatusView( + viewModel: viewModel, + block: block, + blocks: blocks + ) + } } } } } } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 5) + .frameLimit(width: proxy.size.width) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 16) - .padding(.vertical, 5) + .frame(maxWidth: .infinity, maxHeight: .infinity) } - .frame(maxWidth: .infinity, maxHeight: .infinity) } } } diff --git a/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift b/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift index 80b8ce43f..115262c09 100644 --- a/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift @@ -78,33 +78,15 @@ public struct HandoutsUpdatesDetailView: View { ZStack(alignment: .top) { Theme.Colors.background .ignoresSafeArea() - // MARK: - Page Body - VStack(alignment: .leading) { - // MARK: - Handouts - if let handouts { - let formattedHandouts = cssInjector.injectCSS( - colorScheme: colorScheme, - html: handouts, - type: .discovery, - fontSize: idiom == .pad ? 100 : 300, - screenWidth: .infinity - ) - - WebViewHtml(fixBrokenLinks(in: formattedHandouts)) - } else if let html = announcemetsHtml() { - // MARK: - Announcements - WebViewHtml(fixBrokenLinks(in: html)) - } - } + // MARK: - Page Body + WebViewHtml(html(), injections: [.accessibility, .readability]) .padding(.top, 8) - .padding(.horizontal, 32) .frame( maxHeight: .infinity, alignment: .topLeading) .onRightSwipeGesture { router.back() } - Spacer(minLength: 84) } .navigationBarHidden(false) .navigationBarBackButtonHidden(false) @@ -115,6 +97,24 @@ public struct HandoutsUpdatesDetailView: View { } } + func html() -> String { + var html: String = "" + if let handouts { + // MARK: - Handouts + html = cssInjector.injectCSS( + colorScheme: colorScheme, + html: handouts, + type: .discovery, + fontSize: idiom == .pad ? 100 : 300, + screenWidth: .infinity + ) + } else if let announcemetsHtml = announcemetsHtml() { + // MARK: - Announcements + html = announcemetsHtml + } + return fixBrokenLinks(in: html) + } + func fontsCSS(for fontFamily: String) -> String? { if let path = Bundle(for: ThemeBundle.self).path(forResource: "fonts_file", ofType: "ttf"), let data = try? Data(contentsOf: URL(fileURLWithPath: path), options: .mappedIfSafe) { diff --git a/Course/Course/Presentation/Handouts/HandoutsView.swift b/Course/Course/Presentation/Handouts/HandoutsView.swift index 404b6d29a..a7acabc0e 100644 --- a/Course/Course/Presentation/Handouts/HandoutsView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsView.swift @@ -26,10 +26,11 @@ struct HandoutsView: View { } public var body: some View { - ZStack(alignment: .top) { - VStack(alignment: .center) { - - // MARK: - Page Body + GeometryReader { proxy in + ZStack(alignment: .top) { + VStack(alignment: .center) { + + // MARK: - Page Body if viewModel.isShowProgress { HStack(alignment: .center) { ProgressBar(size: 40, lineWidth: 8) @@ -68,45 +69,47 @@ struct HandoutsView: View { }.padding(.horizontal, 32) Spacer(minLength: 84) } - } - - // MARK: - Offline mode SnackBar - OfflineSnackBarView( - connectivity: viewModel.connectivity, - reloadAction: { - Task { - await viewModel.getHandouts(courseID: courseID) - await viewModel.getUpdates(courseID: courseID) - } } - ) - - // MARK: - Error Alert - if viewModel.showError { - VStack { - Spacer() - SnackBarView(message: viewModel.errorMessage) - } - .padding(.bottom, viewModel.connectivity.isInternetAvaliable - ? 0 : OfflineSnackBarView.height) - .transition(.move(edge: .bottom)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.errorMessage = nil + .frameLimit(width: proxy.size.width) + // MARK: - Offline mode SnackBar + OfflineSnackBarView( + connectivity: viewModel.connectivity, + reloadAction: { + Task { + await viewModel.getHandouts(courseID: courseID) + await viewModel.getUpdates(courseID: courseID) + } + } + ) + + // MARK: - Error Alert + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) + } + .padding(.bottom, viewModel.connectivity.isInternetAvaliable + ? 0 : OfflineSnackBarView.height) + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } } } } - } - .onFirstAppear { - Task { - await viewModel.getHandouts(courseID: courseID) - await viewModel.getUpdates(courseID: courseID) + + .onFirstAppear { + Task { + await viewModel.getHandouts(courseID: courseID) + await viewModel.getUpdates(courseID: courseID) + } } + .background( + Theme.Colors.background + .ignoresSafeArea() + ) } - .background( - Theme.Colors.background - .ignoresSafeArea() - ) } } diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index b67b902eb..3c85b1fbc 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -144,11 +144,11 @@ public struct CourseOutlineView: View { } Spacer(minLength: 84) } + .frameLimit(width: proxy.size.width) + } + .onRightSwipeGesture { + viewModel.router.back() } - .frameLimit() - .onRightSwipeGesture { - viewModel.router.back() - } } .padding(.top, viewModel.config.uiComponents.courseTopTabBarEnabled ? 0 : 8) .accessibilityAction {} diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift index 889d6e155..c33025a8f 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift @@ -139,12 +139,13 @@ public struct CourseVerticalView: View { } } } + .frameLimit(width: proxy.size.width) Spacer(minLength: 84) - }.accessibilityAction {} - .frameLimit() - .onRightSwipeGesture { - viewModel.router.back() - } + } + .accessibilityAction {} + .onRightSwipeGesture { + viewModel.router.back() + } } .padding(.top, 8) diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index 73b529d32..de51d8c03 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -168,7 +168,7 @@ public struct CourseUnitView: View { ForEach(data, id: \.offset) { index, block in VStack(spacing: 0) { if isDropdownActive { - dropdown(block: block) + videoTitle(block: block, width: reader.size.width) } switch LessonType.from(block, streamingQuality: viewModel.streamingQuality) { // MARK: YouTube @@ -184,7 +184,8 @@ public struct CourseUnitView: View { languages: block.subtitles ?? [], isOnScreen: index == viewModel.index ) - .frameLimit() + .frameLimit(width: reader.size.width) + if !isHorizontal { Spacer(minLength: 150) } @@ -213,7 +214,8 @@ public struct CourseUnitView: View { isOnScreen: index == viewModel.index ) .padding(.top, 5) - .frameLimit() + .frameLimit(width: reader.size.width) + if !isHorizontal { Spacer(minLength: 150) } @@ -230,6 +232,7 @@ public struct CourseUnitView: View { injections: injections, roundedBackgroundEnabled: !viewModel.courseUnitProgressEnabled ) + // not need to add frame limit there because we did that with injection } else { NoInternetView(playerStateSubject: playerStateSubject) } @@ -242,6 +245,7 @@ public struct CourseUnitView: View { if viewModel.connectivity.isInternetAvaliable { ScrollView(showsIndicators: false) { UnknownView(url: url, viewModel: viewModel) + .frameLimit(width: reader.size.width) Spacer() .frame(minHeight: 100) } @@ -270,7 +274,9 @@ public struct CourseUnitView: View { Color.clear } } - }.frameLimit() + } + //No need iPad paddings there bacause they were added + //to PostsView that placed inside DiscussionView } else { NoInternetView(playerStateSubject: playerStateSubject) } @@ -320,7 +326,7 @@ public struct CourseUnitView: View { return CGPoint(x: x, y: y) } - private func dropdown(block: CourseBlock) -> some View { + private func videoTitle(block: CourseBlock, width: CGFloat) -> some View { HStack { if block.type == .video { let title = block.displayName @@ -333,6 +339,7 @@ public struct CourseUnitView: View { Spacer() } } + .frameLimit(width: width) } // MARK: - Course Navigation @@ -425,7 +432,6 @@ public struct CourseUnitView: View { .padding(.bottom, isHorizontal ? 0 : 50) .padding(.top, isHorizontal ? 12 : 0) } - .frameLimit(sizePortrait: 420) } } diff --git a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift index 586ef427a..9b1d9f153 100644 --- a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift +++ b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift @@ -16,7 +16,7 @@ public enum LessonType: Equatable { case discussion(String, String, String) static func from(_ block: CourseBlock, streamingQuality: StreamingQuality) -> Self { - let mandatoryInjections: [WebviewInjection] = [.colorInversionCss, .ajaxCallback] + let mandatoryInjections: [WebviewInjection] = [.colorInversionCss, .ajaxCallback, .readability, .accessibility] switch block.type { case .course, .chapter, .vertical, .sequential: return .unknown(block.studentUrl) diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift index 7a7703b09..aeef714b3 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift @@ -47,6 +47,7 @@ struct CourseUnitVerticalsDropdownView: View { }) .offset(y: offsetY) .padding(.horizontal, 20) + .frameLimit() } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .transition(.opacity) diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift index d13af950e..17e8be4f6 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift @@ -54,7 +54,7 @@ public struct EncodedVideoPlayer: View { public var body: some View { ZStack { GeometryReader { reader in - VStack { + VStack(spacing: 10) { HStack { VStack { PlayerViewController( @@ -78,7 +78,7 @@ public struct EncodedVideoPlayer: View { currentTime = seconds }) .aspectRatio(16 / 9, contentMode: .fit) - .frame(minWidth: isHorizontal ? reader.size.width * 0.6 : 380) + .frame(minWidth: playerWidth(for: reader.size)) .cornerRadius(12) .onAppear { viewModel.controller.player?.play() @@ -125,7 +125,8 @@ public struct EncodedVideoPlayer: View { } } } - .padding(.horizontal, isHorizontal ? 0 : 8) + .padding(.horizontal, 8) + .statusBarHidden(false) .onDisappear { viewModel.controller.player?.allowsExternalPlayback = false } @@ -140,6 +141,18 @@ public struct EncodedVideoPlayer: View { self.pause = false } } + + private func playerWidth(for size: CGSize) -> CGFloat { + if isHorizontal { + return size.width * 0.6 + } else { + //subtitles is a second half of screen, 10 - space between subtitles and player + let availableHeight = size.height / 2 - 10 + let ratio: CGFloat = 16/9 + let calculatedWidth = availableHeight * ratio + return min(calculatedWidth, size.width) + } + } } #if DEBUG @@ -153,7 +166,7 @@ struct EncodedVideoPlayer_Previews: PreviewProvider { languages: [], playerStateSubject: CurrentValueSubject(nil), interactor: CourseInteractor(repository: CourseRepositoryMock()), - router: CourseRouterMock(), + router: CourseRouterMock(), appStorage: CoreStorageMock(), connectivity: Connectivity() ), diff --git a/Dashboard/Dashboard/Presentation/DashboardView.swift b/Dashboard/Dashboard/Presentation/DashboardView.swift index 9c8b81ab5..c896acfd3 100644 --- a/Dashboard/Dashboard/Presentation/DashboardView.swift +++ b/Dashboard/Dashboard/Presentation/DashboardView.swift @@ -34,104 +34,106 @@ public struct DashboardView: View { } public var body: some View { - ZStack(alignment: .top) { - - // MARK: - Page body - VStack(alignment: .center) { - RefreshableScrollViewCompat(action: { - await viewModel.getMyCourses(page: 1, refresh: true) - }) { - Group { - if viewModel.courses.isEmpty && !viewModel.fetchInProgress { - EmptyPageIcon() - } else { - LazyVStack(spacing: 0) { - HStack { - dashboardCourses + GeometryReader { proxy in + ZStack(alignment: .top) { + + // MARK: - Page body + VStack(alignment: .center) { + RefreshableScrollViewCompat(action: { + await viewModel.getMyCourses(page: 1, refresh: true) + }) { + Group { + if viewModel.courses.isEmpty && !viewModel.fetchInProgress { + EmptyPageIcon() + } else { + LazyVStack(spacing: 0) { + HStack { + dashboardCourses + .padding(.horizontal, 20) + .padding(.bottom, 20) + Spacer() + }.padding(.leading, 10) + ForEach(Array(viewModel.courses.enumerated()), + id: \.offset) { index, course in + + CourseCellView( + model: course, + type: .dashboard, + index: index, + cellsCount: viewModel.courses.count + ) .padding(.horizontal, 20) - .padding(.bottom, 20) - Spacer() - }.padding(.leading, 10) - ForEach(Array(viewModel.courses.enumerated()), - id: \.offset) { index, course in - - CourseCellView( - model: course, - type: .dashboard, - index: index, - cellsCount: viewModel.courses.count - ) - .padding(.horizontal, 20) - .listRowBackground(Color.clear) - .onAppear { - Task { - await viewModel.getMyCoursesPagination(index: index) + .listRowBackground(Color.clear) + .onAppear { + Task { + await viewModel.getMyCoursesPagination(index: index) + } + } + .onTapGesture { + viewModel.trackDashboardCourseClicked( + courseID: course.courseID, + courseName: course.name + ) + router.showCourseScreens( + courseID: course.courseID, + isActive: course.isActive, + courseStart: course.courseStart, + courseEnd: course.courseEnd, + enrollmentStart: course.enrollmentStart, + enrollmentEnd: course.enrollmentEnd, + title: course.name + ) } + .accessibilityIdentifier("course_item") } - .onTapGesture { - viewModel.trackDashboardCourseClicked( - courseID: course.courseID, - courseName: course.name - ) - router.showCourseScreens( - courseID: course.courseID, - isActive: course.isActive, - courseStart: course.courseStart, - courseEnd: course.courseEnd, - enrollmentStart: course.enrollmentStart, - enrollmentEnd: course.enrollmentEnd, - title: course.name - ) + // MARK: - ProgressBar + if viewModel.nextPage <= viewModel.totalPages { + VStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 20) + }.frame(maxWidth: .infinity, + maxHeight: .infinity) } - .accessibilityIdentifier("course_item") - } - // MARK: - ProgressBar - if viewModel.nextPage <= viewModel.totalPages { - VStack(alignment: .center) { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 20) - }.frame(maxWidth: .infinity, - maxHeight: .infinity) + VStack {}.frame(height: 40) } - VStack {}.frame(height: 40) } } + .frameLimit(width: proxy.size.width) + }.accessibilityAction {} + }.padding(.top, 8) + + // MARK: - Offline mode SnackBar + OfflineSnackBarView(connectivity: viewModel.connectivity, + reloadAction: { + await viewModel.getMyCourses(page: 1, refresh: true) + }) + + // MARK: - Error Alert + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) } - }.accessibilityAction {} - .frameLimit() - }.padding(.top, 8) - - // MARK: - Offline mode SnackBar - OfflineSnackBarView(connectivity: viewModel.connectivity, - reloadAction: { - await viewModel.getMyCourses(page: 1, refresh: true) - }) - - // MARK: - Error Alert - if viewModel.showError { - VStack { - Spacer() - SnackBarView(message: viewModel.errorMessage) - } - .padding(.bottom, viewModel.connectivity.isInternetAvaliable - ? 0 : OfflineSnackBarView.height) - .transition(.move(edge: .bottom)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.errorMessage = nil + .padding(.bottom, viewModel.connectivity.isInternetAvaliable + ? 0 : OfflineSnackBarView.height) + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } } } } - } - .onFirstAppear { - Task { - await viewModel.getMyCourses(page: 1) + .onFirstAppear { + Task { + await viewModel.getMyCourses(page: 1) + } } + .background( + Theme.Colors.background + .ignoresSafeArea() + ) } - .background( - Theme.Colors.background - .ignoresSafeArea() - ) } } diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift index b8a76a400..c4ed19e32 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift @@ -137,10 +137,11 @@ public struct CourseDetailsView: View { } } } - }.frameLimit() - .onRightSwipeGesture { - viewModel.router.back() - } + .frameLimit(width: proxy.size.width) + } + .onRightSwipeGesture { + viewModel.router.back() + } Spacer(minLength: 84) } } diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift index 973271ecc..b8d9aa860 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift @@ -48,150 +48,153 @@ public struct DiscoveryView: View { } public var body: some View { - ZStack(alignment: .top) { - - // MARK: - Page name - VStack(alignment: .center) { + GeometryReader { proxy in + ZStack(alignment: .top) { - // MARK: - Search fake field - HStack(spacing: 11) { - Image(systemName: "magnifyingglass") - .foregroundColor(Theme.Colors.textSecondary) - .padding(.leading, 16) - .padding(.top, 1) - .accessibilityIdentifier("search_image") - Text(DiscoveryLocalization.search) - .foregroundColor(Theme.Colors.textSecondary) - .accessibilityIdentifier("search_text") - Spacer() - } - .onTapGesture { - router.showDiscoverySearch(searchQuery: searchQuery) - viewModel.discoverySearchBarClicked() - } - .frame(minHeight: 48) - .frame(maxWidth: .infinity) - .background( - Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputUnfocusedBackground) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill(Theme.Colors.textInputUnfocusedStroke) - ).onTapGesture { - router.showDiscoverySearch(searchQuery: searchQuery) - viewModel.discoverySearchBarClicked() - } - .padding(.top, 11.5) - .padding(.horizontal, 24) - .padding(.bottom, 20) - .accessibilityElement(children: .ignore) - .accessibilityLabel(DiscoveryLocalization.search) - - ZStack { - RefreshableScrollViewCompat(action: { - viewModel.totalPages = 1 - viewModel.nextPage = 1 - Task { - await viewModel.discovery(page: 1, withProgress: false) - } - }) { - LazyVStack(spacing: 0) { - HStack { - discoveryNew - .padding(.horizontal, 20) - .padding(.bottom, 20) - Spacer() - }.padding(.leading, 10) - ForEach(Array(viewModel.courses.enumerated()), id: \.offset) { index, course in - CourseCellView( - model: course, - type: .discovery, - index: index, - cellsCount: viewModel.courses.count - ).padding(.horizontal, 24) - .onAppear { - Task { - await viewModel.getDiscoveryCourses(index: index) + // MARK: - Page name + VStack(alignment: .center) { + + // MARK: - Search fake field + HStack(spacing: 11) { + Image(systemName: "magnifyingglass") + .foregroundColor(Theme.Colors.textSecondary) + .padding(.leading, 16) + .padding(.top, 1) + .accessibilityIdentifier("search_image") + Text(DiscoveryLocalization.search) + .foregroundColor(Theme.Colors.textSecondary) + .accessibilityIdentifier("search_text") + Spacer() + } + .onTapGesture { + router.showDiscoverySearch(searchQuery: searchQuery) + viewModel.discoverySearchBarClicked() + } + .frame(minHeight: 48) + .frame(maxWidth: .infinity) + .background( + Theme.Shapes.textInputShape + .fill(Theme.Colors.textInputUnfocusedBackground) + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(Theme.Colors.textInputUnfocusedStroke) + ).onTapGesture { + router.showDiscoverySearch(searchQuery: searchQuery) + viewModel.discoverySearchBarClicked() + } + .padding(.top, 11.5) + .padding(.horizontal, 24) + .padding(.bottom, 20) + .frameLimit(width: proxy.size.width) + .accessibilityElement(children: .ignore) + .accessibilityLabel(DiscoveryLocalization.search) + + ZStack { + RefreshableScrollViewCompat(action: { + viewModel.totalPages = 1 + viewModel.nextPage = 1 + Task { + await viewModel.discovery(page: 1, withProgress: false) + } + }) { + LazyVStack(spacing: 0) { + HStack { + discoveryNew + .padding(.horizontal, 20) + .padding(.bottom, 20) + Spacer() + }.padding(.leading, 10) + ForEach(Array(viewModel.courses.enumerated()), id: \.offset) { index, course in + CourseCellView( + model: course, + type: .discovery, + index: index, + cellsCount: viewModel.courses.count + ).padding(.horizontal, 24) + .onAppear { + Task { + await viewModel.getDiscoveryCourses(index: index) + } } - } - .onTapGesture { - viewModel.discoveryCourseClicked( - courseID: course.courseID, - courseName: course.name - ) - viewModel.router.showCourseDetais( - courseID: course.courseID, - title: course.name - ) - } + .onTapGesture { + viewModel.discoveryCourseClicked( + courseID: course.courseID, + courseName: course.name + ) + viewModel.router.showCourseDetais( + courseID: course.courseID, + title: course.name + ) + } + } + + // MARK: - ProgressBar + if viewModel.nextPage <= viewModel.totalPages { + VStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 20) + }.frame(maxWidth: .infinity, + maxHeight: .infinity) + } + VStack {}.frame(height: 40) } - - // MARK: - ProgressBar - if viewModel.nextPage <= viewModel.totalPages { - VStack(alignment: .center) { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 20) - }.frame(maxWidth: .infinity, - maxHeight: .infinity) + .frameLimit(width: proxy.size.width) + } + }.accessibilityAction {} + + if !viewModel.userloggedIn { + LogistrationBottomView { buttonAction in + switch buttonAction { + case .signIn: + viewModel.router.showLoginScreen(sourceScreen: .discovery) + case .register: + viewModel.router.showRegisterScreen(sourceScreen: .discovery) } - VStack {}.frame(height: 40) } } - .frameLimit() - }.accessibilityAction {} + }.padding(.top, 8) + + // MARK: - Offline mode SnackBar + OfflineSnackBarView( + connectivity: viewModel.connectivity, + reloadAction: { + await viewModel.discovery(page: 1, withProgress: false) + }) - if !viewModel.userloggedIn { - LogistrationBottomView { buttonAction in - switch buttonAction { - case .signIn: - viewModel.router.showLoginScreen(sourceScreen: .discovery) - case .register: - viewModel.router.showRegisterScreen(sourceScreen: .discovery) + // MARK: - Error Alert + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) + } + .padding(.bottom, viewModel.connectivity.isInternetAvaliable + ? 0 : OfflineSnackBarView.height) + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil } } } - }.padding(.top, 8) - - // MARK: - Offline mode SnackBar - OfflineSnackBarView( - connectivity: viewModel.connectivity, - reloadAction: { - await viewModel.discovery(page: 1, withProgress: false) - }) - - // MARK: - Error Alert - if viewModel.showError { - VStack { - Spacer() - SnackBarView(message: viewModel.errorMessage) + } + .navigationBarHidden(sourceScreen != .startup) + .onFirstAppear { + if !(searchQuery.isEmpty) { + router.showDiscoverySearch(searchQuery: searchQuery) + searchQuery = "" } - .padding(.bottom, viewModel.connectivity.isInternetAvaliable - ? 0 : OfflineSnackBarView.height) - .transition(.move(edge: .bottom)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.errorMessage = nil + Task { + await viewModel.discovery(page: 1) + if case let .courseDetail(courseID, courseTitle) = sourceScreen { + viewModel.router.showCourseDetais(courseID: courseID, title: courseTitle) } } + viewModel.setupNotifications() } + .background(Theme.Colors.background.ignoresSafeArea()) } - .navigationBarHidden(sourceScreen != .startup) - .onFirstAppear { - if !(searchQuery.isEmpty) { - router.showDiscoverySearch(searchQuery: searchQuery) - searchQuery = "" - } - Task { - await viewModel.discovery(page: 1) - if case let .courseDetail(courseID, courseTitle) = sourceScreen { - viewModel.router.showCourseDetais(courseID: courseID, title: courseTitle) - } - } - viewModel.setupNotifications() - } - .background(Theme.Colors.background.ignoresSafeArea()) } } diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift index 2fe227582..7f123a28a 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift @@ -25,138 +25,143 @@ public struct SearchView: View { } public var body: some View { - ZStack(alignment: .top) { - - // MARK: - Page name - VStack(alignment: .center) { - NavigationBar(title: DiscoveryLocalization.search, - leftButtonAction: { - viewModel.router.backWithFade() - }).padding(.bottom, -7) + GeometryReader { proxy in + ZStack(alignment: .top) { - HStack(spacing: 11) { - Image(systemName: "magnifyingglass") - .foregroundColor(Theme.Colors.textPrimary) - .padding(.leading, 16) - .padding(.top, 1) - .foregroundColor( - viewModel.isSearchActive - ? Theme.Colors.accentColor - : Theme.Colors.textPrimary - ) - .accessibilityHidden(true) - .accessibilityIdentifier("search_image") + // MARK: - Page name + VStack(alignment: .center) { + NavigationBar(title: DiscoveryLocalization.search, + leftButtonAction: { + viewModel.router.backWithFade() + }).padding(.bottom, -7) - TextField( - !viewModel.isSearchActive - ? DiscoveryLocalization.search - : "", - text: $viewModel.searchText, - onEditingChanged: { editing in - viewModel.isSearchActive = editing - } - ).focused($focused) - .onAppear { - self.focused = true + HStack(spacing: 11) { + Image(systemName: "magnifyingglass") + .foregroundColor(Theme.Colors.textPrimary) + .padding(.leading, 16) + .padding(.top, 1) + .foregroundColor( + viewModel.isSearchActive + ? Theme.Colors.accentColor + : Theme.Colors.textPrimary + ) + .accessibilityHidden(true) + .accessibilityIdentifier("search_image") + + TextField( + !viewModel.isSearchActive + ? DiscoveryLocalization.search + : "", + text: $viewModel.searchText, + onEditingChanged: { editing in + viewModel.isSearchActive = editing + } + ).focused($focused) + .onAppear { + self.focused = true + } + .foregroundColor(Theme.Colors.textPrimary) + .font(Theme.Fonts.bodyLarge) + .accessibilityIdentifier("search_textfields") + Spacer() + if !viewModel.searchText.trimmingCharacters(in: .whitespaces).isEmpty { + Button(action: { viewModel.searchText.removeAll() }, label: { + CoreAssets.clearInput.swiftUIImage + .resizable() + .scaledToFit() + .frame(height: 24) + .padding(.horizontal) + }) + .foregroundColor(Theme.Colors.styledButtonText) + .accessibilityIdentifier("search_button") } - .foregroundColor(Theme.Colors.textPrimary) - .font(Theme.Fonts.bodyLarge) - .accessibilityIdentifier("search_textfields") - Spacer() - if !viewModel.searchText.trimmingCharacters(in: .whitespaces).isEmpty { - Button(action: { viewModel.searchText.removeAll() }, label: { - CoreAssets.clearInput.swiftUIImage - .resizable() - .scaledToFit() - .frame(height: 24) - .padding(.horizontal) - }) - .foregroundColor(Theme.Colors.styledButtonText) - .accessibilityIdentifier("search_button") } - } - .frame(minHeight: 48) - .frame(maxWidth: .infinity) - .background( - Theme.Shapes.textInputShape - .fill(viewModel.isSearchActive - ? Theme.Colors.textInputBackground - : Theme.Colors.textInputUnfocusedBackground) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill(viewModel.isSearchActive - ? Theme.Colors.accentColor - : Theme.Colors.textInputUnfocusedStroke) - ) - .padding(.horizontal, 24) - .padding(.bottom, 20) - - ZStack { - ScrollView { - HStack { - searchHeader(viewModel: viewModel) - .padding(.horizontal, 24) - .padding(.bottom, 20) - .offset(y: animated ? 0 : 50) - .opacity(animated ? 1 : 0) - Spacer() - }.padding(.leading, 10) - - LazyVStack { - let searchResults = viewModel.searchResults.enumerated() - ForEach( - Array(searchResults), id: \.offset) { index, course in - CourseCellView(model: course, - type: .discovery, - index: index, - cellsCount: viewModel.searchResults.count) + .frame(minHeight: 48) + .frame(maxWidth: .infinity) + .background( + Theme.Shapes.textInputShape + .fill(viewModel.isSearchActive + ? Theme.Colors.textInputBackground + : Theme.Colors.textInputUnfocusedBackground) + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(viewModel.isSearchActive + ? Theme.Colors.accentColor + : Theme.Colors.textInputUnfocusedStroke) + ) + .padding(.horizontal, 24) + .padding(.bottom, 20) + .frameLimit(width: proxy.size.width) + + ZStack { + ScrollView { + HStack { + searchHeader(viewModel: viewModel) .padding(.horizontal, 24) - .onAppear { - Task { - await viewModel.searchCourses( - index: index, - searchTerm: viewModel.searchText + .padding(.bottom, 20) + .offset(y: animated ? 0 : 50) + .opacity(animated ? 1 : 0) + Spacer() + } + .padding(.leading, 10) + .frameLimit(width: proxy.size.width) + + LazyVStack { + let searchResults = viewModel.searchResults.enumerated() + ForEach( + Array(searchResults), id: \.offset) { index, course in + CourseCellView(model: course, + type: .discovery, + index: index, + cellsCount: viewModel.searchResults.count) + .padding(.horizontal, 24) + .onAppear { + Task { + await viewModel.searchCourses( + index: index, + searchTerm: viewModel.searchText + ) + } + } + .onTapGesture { + viewModel.router.showCourseDetais( + courseID: course.courseID, + title: course.name ) } } - .onTapGesture { - viewModel.router.showCourseDetais( - courseID: course.courseID, - title: course.name - ) - } + // MARK: - ProgressBar + if viewModel.fetchInProgress { + VStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 20) + }.frame(maxWidth: .infinity, + maxHeight: .infinity) } - // MARK: - ProgressBar - if viewModel.fetchInProgress { - VStack(alignment: .center) { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 20) - }.frame(maxWidth: .infinity, - maxHeight: .infinity) } + .frameLimit(width: proxy.size.width) + Spacer(minLength: 40) } - Spacer(minLength: 40) - }.frameLimit() - } - } - // MARK: - Error Alert - if viewModel.showError { - VStack { - Spacer() - SnackBarView(message: viewModel.errorMessage) + } } - .transition(.move(edge: .bottom)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.errorMessage = nil + // MARK: - Error Alert + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) + } + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } } } } - } - .navigationBarBackButtonHidden(true) - .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + .navigationBarHidden(true) .onAppear { DispatchQueue.main.asyncAfter(deadline: .now()) { withAnimation(.easeIn(duration: 0.3)) { @@ -164,12 +169,13 @@ public struct SearchView: View { } } } - + .onDisappear { viewModel.searchText = "" } .background(Theme.Colors.background.ignoresSafeArea()) .addTapToEndEditing(isForced: true) + } } private func searchHeader(viewModel: SearchViewModel) -> some View { diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index f364e0a32..cdb0dabe5 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -36,179 +36,182 @@ public struct ResponsesView: View { } public var body: some View { - ZStack(alignment: .top) { - // MARK: - Page Body - ScrollViewReader { scroll in - VStack { - ZStack(alignment: .top) { - RefreshableScrollViewCompat(action: { - viewModel.comments = [] - _ = await viewModel.getComments( - commentID: commentID, - parentComment: parentComment, - page: 1 - ) - }) { - VStack { - if let comments = viewModel.postComments { - ParentCommentView( - comments: comments, - isThread: false, onAvatarTap: { username in - viewModel.router.showUserDetails(username: username) - }, - onLikeTap: { - Task { - if await viewModel.vote( - id: parentComment.commentID, - isThread: false, - voted: comments.voted, - index: nil - ) { - viewModel.sendThreadLikeState() - } - } - }, - onReportTap: { - Task { - if await viewModel.flag( - id: parentComment.commentID, - isThread: false, - abuseFlagged: comments.abuseFlagged, - index: nil - ) { - viewModel.sendThreadReportState() - } - - } - }, - onFollowTap: {} - ) - HStack { - Text("\(viewModel.itemsCount)") - Text(DiscussionLocalization.commentsCount(viewModel.itemsCount)) - Spacer() - } - .padding(.top, 20) - .padding(.bottom, 14) - .padding(.leading, 24) - .font(Theme.Fonts.titleMedium) - .foregroundColor(Theme.Colors.textPrimary) - ForEach( - Array(comments.comments.enumerated()), id: \.offset - ) { index, comment in - CommentCell( - comment: comment, - addCommentAvailable: false, leftLineEnabled: true, - onAvatarTap: { username in + GeometryReader { proxy in + ZStack(alignment: .top) { + // MARK: - Page Body + ScrollViewReader { scroll in + VStack { + ZStack(alignment: .top) { + RefreshableScrollViewCompat(action: { + viewModel.comments = [] + _ = await viewModel.getComments( + commentID: commentID, + parentComment: parentComment, + page: 1 + ) + }) { + VStack { + if let comments = viewModel.postComments { + ParentCommentView( + comments: comments, + isThread: false, onAvatarTap: { username in viewModel.router.showUserDetails(username: username) }, onLikeTap: { Task { - await viewModel.vote( - id: comment.commentID, + if await viewModel.vote( + id: parentComment.commentID, isThread: false, - voted: comment.voted, - index: index - ) + voted: comments.voted, + index: nil + ) { + viewModel.sendThreadLikeState() + } } }, onReportTap: { Task { - await viewModel.flag( - id: comment.commentID, + if await viewModel.flag( + id: parentComment.commentID, isThread: false, - abuseFlagged: comment.abuseFlagged, - index: index - ) + abuseFlagged: comments.abuseFlagged, + index: nil + ) { + viewModel.sendThreadReportState() + } + } }, - onCommentsTap: {}, - onFetchMore: { - Task { - await viewModel.fetchMorePosts( - commentID: commentID, - parentComment: parentComment, - index: index - ) - } - } + onFollowTap: {} ) - .id(index) - .padding(.bottom, -8) - } - if viewModel.nextPage <= viewModel.totalPages { - VStack(alignment: .center) { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 20) - }.frame(maxWidth: .infinity, - maxHeight: .infinity) + HStack { + Text("\(viewModel.itemsCount)") + Text(DiscussionLocalization.commentsCount(viewModel.itemsCount)) + Spacer() + } + .padding(.top, 20) + .padding(.bottom, 14) + .padding(.leading, 24) + .font(Theme.Fonts.titleMedium) + .foregroundColor(Theme.Colors.textPrimary) + ForEach( + Array(comments.comments.enumerated()), id: \.offset + ) { index, comment in + CommentCell( + comment: comment, + addCommentAvailable: false, leftLineEnabled: true, + onAvatarTap: { username in + viewModel.router.showUserDetails(username: username) + }, + onLikeTap: { + Task { + await viewModel.vote( + id: comment.commentID, + isThread: false, + voted: comment.voted, + index: index + ) + } + }, + onReportTap: { + Task { + await viewModel.flag( + id: comment.commentID, + isThread: false, + abuseFlagged: comment.abuseFlagged, + index: index + ) + } + }, + onCommentsTap: {}, + onFetchMore: { + Task { + await viewModel.fetchMorePosts( + commentID: commentID, + parentComment: parentComment, + index: index + ) + } + } + ) + .id(index) + .padding(.bottom, -8) + } + if viewModel.nextPage <= viewModel.totalPages { + VStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 20) + }.frame(maxWidth: .infinity, + maxHeight: .infinity) + } } + Spacer(minLength: 84) } - Spacer(minLength: 84) - } - .onRightSwipeGesture { - viewModel.router.back() + .onRightSwipeGesture { + viewModel.router.back() + } + .frameLimit(width: proxy.size.width) } - }.frameLimit() - - if !parentComment.closed { - FlexibleKeyboardInputView( - hint: DiscussionLocalization.Response.addComment, - sendText: { commentText in - if let threadID = viewModel.postComments?.threadID { - Task { - await viewModel.postComment( - threadID: threadID, - rawBody: commentText, - parentID: commentID - ) + + if !parentComment.closed { + FlexibleKeyboardInputView( + hint: DiscussionLocalization.Response.addComment, + sendText: { commentText in + if let threadID = viewModel.postComments?.threadID { + Task { + await viewModel.postComment( + threadID: threadID, + rawBody: commentText, + parentID: commentID + ) + } } } - } - ).ignoresSafeArea(.all, edges: .horizontal) + ).ignoresSafeArea(.all, edges: .horizontal) + } } } - } - .onReceive(viewModel.addPostSubject, perform: { newComment in - guard let newComment else { return } - viewModel.sendThreadPostsCountState() - if viewModel.nextPage - 1 == viewModel.totalPages { - viewModel.addNewPost(newComment) - withAnimation { - guard let count = viewModel.postComments?.comments.count else { return } - scroll.scrollTo(count - 2, anchor: .top) + .onReceive(viewModel.addPostSubject, perform: { newComment in + guard let newComment else { return } + viewModel.sendThreadPostsCountState() + if viewModel.nextPage - 1 == viewModel.totalPages { + viewModel.addNewPost(newComment) + withAnimation { + guard let count = viewModel.postComments?.comments.count else { return } + scroll.scrollTo(count - 2, anchor: .top) + } + } else { + viewModel.alertMessage = DiscussionLocalization.Response.Alert.commentAdded + viewModel.showAlert = true } - } else { - viewModel.alertMessage = DiscussionLocalization.Response.Alert.commentAdded - viewModel.showAlert = true + }) + .frame(maxWidth: .infinity, maxHeight: .infinity) + }.scrollAvoidKeyboard(dismissKeyboardByTap: true) + .padding(.top, 8) + // MARK: - Error Alert + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) } - }) - .frame(maxWidth: .infinity, maxHeight: .infinity) - }.scrollAvoidKeyboard(dismissKeyboardByTap: true) - .padding(.top, 8) - // MARK: - Error Alert - if viewModel.showError { - VStack { - Spacer() - SnackBarView(message: viewModel.errorMessage) - } - .transition(.move(edge: .bottom)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.errorMessage = nil + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } } } } + .ignoresSafeArea(.all, edges: .horizontal) + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(title) + .edgesIgnoringSafeArea(.bottom) + .background( + Theme.Colors.background + .ignoresSafeArea() + ) } - .ignoresSafeArea(.all, edges: .horizontal) - .navigationBarHidden(false) - .navigationBarBackButtonHidden(false) - .navigationTitle(title) - .edgesIgnoringSafeArea(.bottom) - .background( - Theme.Colors.background - .ignoresSafeArea() - ) } } diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index 4b2374812..4ee80b578 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -29,216 +29,218 @@ public struct ThreadView: View { } public var body: some View { - ZStack(alignment: .top) { - - // MARK: - Page Body - ScrollViewReader { scroll in - VStack { - ZStack(alignment: .top) { - RefreshableScrollViewCompat(action: { - _ = await viewModel.getPosts(thread: thread, page: 1) - }) { - VStack { - if let comments = viewModel.postComments { - ParentCommentView( - comments: comments, - isThread: true, - onAvatarTap: { username in - viewModel.router.showUserDetails(username: username) - }, - onLikeTap: { - Task { - if await viewModel.vote( - id: comments.threadID, - isThread: true, - voted: comments.voted, - index: nil - ) { - viewModel.sendPostLikedState() - } - } - }, - onReportTap: { - Task { - if await viewModel.flag( - id: comments.threadID, - isThread: true, - abuseFlagged: comments.abuseFlagged, - index: nil - ) { - viewModel.sendReportedState() - } - } - }, - onFollowTap: { - Task { - if await viewModel.followThread( - following: comments.followed, - threadID: comments.threadID - ) { - viewModel.sendPostFollowedState() - } - } - } - ) - - HStack { - Text("\(viewModel.itemsCount)") - Text(DiscussionLocalization.responsesCount(viewModel.itemsCount)) - Spacer() - } - .padding(.top, 20) - .padding(.leading, 24) - .font(Theme.Fonts.titleMedium) - .foregroundColor(Theme.Colors.textPrimary) - - ForEach(Array(comments.comments.enumerated()), id: \.offset) { index, comment in - CommentCell( - comment: comment, - addCommentAvailable: true, + GeometryReader { proxy in + ZStack(alignment: .top) { + + // MARK: - Page Body + ScrollViewReader { scroll in + VStack { + ZStack(alignment: .top) { + RefreshableScrollViewCompat(action: { + _ = await viewModel.getPosts(thread: thread, page: 1) + }) { + VStack { + if let comments = viewModel.postComments { + ParentCommentView( + comments: comments, + isThread: true, onAvatarTap: { username in viewModel.router.showUserDetails(username: username) }, onLikeTap: { Task { - await viewModel.vote( - id: comment.commentID, - isThread: false, - voted: comment.voted, - index: index - ) + if await viewModel.vote( + id: comments.threadID, + isThread: true, + voted: comments.voted, + index: nil + ) { + viewModel.sendPostLikedState() + } } }, onReportTap: { Task { - await viewModel.flag( - id: comment.commentID, - isThread: false, - abuseFlagged: comment.abuseFlagged, - index: index - ) + if await viewModel.flag( + id: comments.threadID, + isThread: true, + abuseFlagged: comments.abuseFlagged, + index: nil + ) { + viewModel.sendReportedState() + } } }, - onCommentsTap: { - viewModel.router.showComments( - commentID: comment.commentID, - parentComment: comment, - threadStateSubject: viewModel.threadStateSubject, - animated: true - ) - }, - onFetchMore: { + onFollowTap: { Task { - await viewModel.fetchMorePosts(thread: thread, - index: index) + if await viewModel.followThread( + following: comments.followed, + threadID: comments.threadID + ) { + viewModel.sendPostFollowedState() + } } } ) - .id(index) - } - if viewModel.nextPage <= viewModel.totalPages { - VStack(alignment: .center) { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 20) + + HStack { + Text("\(viewModel.itemsCount)") + Text(DiscussionLocalization.responsesCount(viewModel.itemsCount)) + Spacer() + } + .padding(.top, 20) + .padding(.leading, 24) + .font(Theme.Fonts.titleMedium) + .foregroundColor(Theme.Colors.textPrimary) + + ForEach(Array(comments.comments.enumerated()), id: \.offset) { index, comment in + CommentCell( + comment: comment, + addCommentAvailable: true, + onAvatarTap: { username in + viewModel.router.showUserDetails(username: username) + }, + onLikeTap: { + Task { + await viewModel.vote( + id: comment.commentID, + isThread: false, + voted: comment.voted, + index: index + ) + } + }, + onReportTap: { + Task { + await viewModel.flag( + id: comment.commentID, + isThread: false, + abuseFlagged: comment.abuseFlagged, + index: index + ) + } + }, + onCommentsTap: { + viewModel.router.showComments( + commentID: comment.commentID, + parentComment: comment, + threadStateSubject: viewModel.threadStateSubject, + animated: true + ) + }, + onFetchMore: { + Task { + await viewModel.fetchMorePosts(thread: thread, + index: index) + } + } + ) + .id(index) + } + if viewModel.nextPage <= viewModel.totalPages { + VStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 20) + } } + Spacer(minLength: 84) } - Spacer(minLength: 84) } + .onRightSwipeGesture { + viewModel.router.back() + onBackTapped() + viewModel.sendUpdateUnreadState() + } + .frameLimit(width: proxy.size.width) } - .frameLimit() - .onRightSwipeGesture { - viewModel.router.back() - onBackTapped() - viewModel.sendUpdateUnreadState() - } - } - if !thread.closed { - FlexibleKeyboardInputView( - hint: DiscussionLocalization.Thread.addResponse, - sendText: { commentText in - if let threadID = viewModel.postComments?.threadID { - Task { - await viewModel.postComment( - threadID: threadID, - rawBody: commentText, - parentID: viewModel.postComments?.parentID - ) + if !thread.closed { + FlexibleKeyboardInputView( + hint: DiscussionLocalization.Thread.addResponse, + sendText: { commentText in + if let threadID = viewModel.postComments?.threadID { + Task { + await viewModel.postComment( + threadID: threadID, + rawBody: commentText, + parentID: viewModel.postComments?.parentID + ) + } } } - } - ).ignoresSafeArea(.all, edges: .horizontal) + ).ignoresSafeArea(.all, edges: .horizontal) + } } - } - .onReceive(viewModel.addPostSubject, perform: { newComment in - guard let newComment else { return } - viewModel.sendPostRepliesCountState() - if viewModel.nextPage - 1 == viewModel.totalPages { - viewModel.addNewPost(newComment) - withAnimation { - guard let count = viewModel.postComments?.comments.count else { return } - scroll.scrollTo(count - 2, anchor: .top) + .onReceive(viewModel.addPostSubject, perform: { newComment in + guard let newComment else { return } + viewModel.sendPostRepliesCountState() + if viewModel.nextPage - 1 == viewModel.totalPages { + viewModel.addNewPost(newComment) + withAnimation { + guard let count = viewModel.postComments?.comments.count else { return } + scroll.scrollTo(count - 2, anchor: .top) + } + } else { + viewModel.alertMessage = DiscussionLocalization.Thread.Alert.commentAdded + viewModel.showAlert = true } - } else { - viewModel.alertMessage = DiscussionLocalization.Thread.Alert.commentAdded - viewModel.showAlert = true + }) + .frame(maxWidth: .infinity, maxHeight: .infinity) + }.scrollAvoidKeyboard(dismissKeyboardByTap: true) + } + .padding(.top, 8) + // MARK: - Error Alert + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) + } + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil } - }) - .frame(maxWidth: .infinity, maxHeight: .infinity) - }.scrollAvoidKeyboard(dismissKeyboardByTap: true) - } - .padding(.top, 8) - // MARK: - Error Alert - if viewModel.showError { - VStack { - Spacer() - SnackBarView(message: viewModel.errorMessage) + } } - .transition(.move(edge: .bottom)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.errorMessage = nil + + // MARK: - Alert + if viewModel.showAlert { + VStack { + Text(viewModel.alertMessage ?? "") + .shadowCardStyle( + bgColor: Theme.Colors.accentColor, + textColor: Theme.Colors.white + ) + .padding(.top, 80) + Spacer() + + } + .transition(.move(edge: .top)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.alertMessage = nil + } } } } - - // MARK: - Alert - if viewModel.showAlert { - VStack { - Text(viewModel.alertMessage ?? "") - .shadowCardStyle( - bgColor: Theme.Colors.accentColor, - textColor: Theme.Colors.white - ) - .padding(.top, 80) - Spacer() - - } - .transition(.move(edge: .top)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.alertMessage = nil - } + .ignoresSafeArea(.all, edges: .horizontal) + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(title) + .onFirstAppear { + Task { + await viewModel.getPosts(thread: thread, page: 1) } } - } - .ignoresSafeArea(.all, edges: .horizontal) - .navigationBarHidden(false) - .navigationBarBackButtonHidden(false) - .navigationTitle(title) - .onFirstAppear { - Task { - await viewModel.getPosts(thread: thread, page: 1) + .onDisappear { + onBackTapped() + viewModel.sendUpdateUnreadState() } + .edgesIgnoringSafeArea(.bottom) + .background( + Theme.Colors.background + .ignoresSafeArea() + ) } - .onDisappear { - onBackTapped() - viewModel.sendUpdateUnreadState() - } - .edgesIgnoringSafeArea(.bottom) - .background( - Theme.Colors.background - .ignoresSafeArea() - ) } private func reloadPage(onSuccess: @escaping () -> Void) { diff --git a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift index 6fe53baea..bdbd4c033 100644 --- a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift +++ b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift @@ -40,163 +40,165 @@ public struct CreateNewThreadView: View { } public var body: some View { - ZStack(alignment: .top) { - VStack(alignment: .center) { - - // MARK: - Page Body - if viewModel.isShowProgress { - HStack(alignment: .center) { - ProgressBar(size: 40, lineWidth: 8) - .padding(20) - }.frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - ScrollView { - VStack(alignment: .leading) { - HStack { - Text(DiscussionLocalization.CreateThread.selectPostType) - .font(Theme.Fonts.titleMedium) - .foregroundColor(Theme.Colors.textPrimary) - .padding(.top, 32) - Spacer() - } - - Picker("", selection: $postType) { - ForEach(postTypes, id: \.self) { - Text($0.localizedValue.capitalized) - .font(Theme.Fonts.bodySmall) + GeometryReader { proxy in + ZStack(alignment: .top) { + VStack(alignment: .center) { + + // MARK: - Page Body + if viewModel.isShowProgress { + HStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(20) + }.frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + VStack(alignment: .leading) { + HStack { + Text(DiscussionLocalization.CreateThread.selectPostType) + .font(Theme.Fonts.titleMedium) + .foregroundColor(Theme.Colors.textPrimary) + .padding(.top, 32) + Spacer() } - }.pickerStyle(.segmented) - .frame(maxWidth: .infinity, maxHeight: 40) - - // MARK: Topic picker - Group { - Text(DiscussionLocalization.CreateThread.topic) - .foregroundColor(Theme.Colors.textPrimary) - .font(Theme.Fonts.titleSmall) - .padding(.top, 16) - Menu { - Picker(selection: $viewModel.selectedTopic) { - ForEach(viewModel.allTopics, id: \.id) { - Text($0.name) - .tag($0.id) - .font(Theme.Fonts.labelLarge) - } - } label: {} - } label: { - HStack { - Text(viewModel.allTopics.first(where: { - $0.id == viewModel.selectedTopic })?.name ?? "") - .font(Theme.Fonts.labelLarge) + Picker("", selection: $postType) { + ForEach(postTypes, id: \.self) { + Text($0.localizedValue.capitalized) + .font(Theme.Fonts.bodySmall) + } + }.pickerStyle(.segmented) + .frame(maxWidth: .infinity, maxHeight: 40) + + // MARK: Topic picker + Group { + Text(DiscussionLocalization.CreateThread.topic) .foregroundColor(Theme.Colors.textPrimary) - .frame(height: 40, alignment: .leading) - Spacer() - Image(systemName: "chevron.down") - }.padding(.horizontal, 14) - .accentColor(Theme.Colors.textPrimary) - .background(Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputBackground) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill(Theme.Colors.textInputStroke) - ) + .font(Theme.Fonts.titleSmall) + .padding(.top, 16) + + Menu { + Picker(selection: $viewModel.selectedTopic) { + ForEach(viewModel.allTopics, id: \.id) { + Text($0.name) + .tag($0.id) + .font(Theme.Fonts.labelLarge) + } + } label: {} + } label: { + HStack { + Text(viewModel.allTopics.first(where: { + $0.id == viewModel.selectedTopic })?.name ?? "") + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + .frame(height: 40, alignment: .leading) + Spacer() + Image(systemName: "chevron.down") + }.padding(.horizontal, 14) + .accentColor(Theme.Colors.textPrimary) + .background(Theme.Shapes.textInputShape + .fill(Theme.Colors.textInputBackground) + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(Theme.Colors.textInputStroke) + ) + } } - } - // MARK: End of topic picker - - Group { - Text(DiscussionLocalization.CreateThread.title) - .font(Theme.Fonts.titleSmall) + // MARK: End of topic picker + + Group { + Text(DiscussionLocalization.CreateThread.title) + .font(Theme.Fonts.titleSmall) + .foregroundColor(Theme.Colors.textPrimary) + + Text(" *").foregroundColor(Theme.Colors.alert) + }.padding(.top, 16) + TextField("", text: $postTitle) + .font(Theme.Fonts.bodyLarge) .foregroundColor(Theme.Colors.textPrimary) - + Text(" *").foregroundColor(Theme.Colors.alert) - }.padding(.top, 16) - TextField("", text: $postTitle) - .font(Theme.Fonts.bodyLarge) - .foregroundColor(Theme.Colors.textPrimary) - .padding(14) - .frame(height: 40) - .background( - Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputBackground) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill( - Theme.Colors.textInputStroke - ) - ) - - Group { - Text("\(postType.localizedValue.capitalized)") - .font(Theme.Fonts.titleSmall) + .padding(14) + .frame(height: 40) + .background( + Theme.Shapes.textInputShape + .fill(Theme.Colors.textInputBackground) + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill( + Theme.Colors.textInputStroke + ) + ) + + Group { + Text("\(postType.localizedValue.capitalized)") + .font(Theme.Fonts.titleSmall) + .foregroundColor(Theme.Colors.textPrimary) + + Text(" *").foregroundColor(Theme.Colors.alert) + }.padding(.top, 16) + TextEditor(text: $postBody) + .font(Theme.Fonts.bodyMedium) .foregroundColor(Theme.Colors.textPrimary) - + Text(" *").foregroundColor(Theme.Colors.alert) - }.padding(.top, 16) - TextEditor(text: $postBody) - .font(Theme.Fonts.bodyMedium) - .foregroundColor(Theme.Colors.textPrimary) - .padding(.horizontal, 10) - .padding(.vertical, 10) - .frame(height: 200) - .hideScrollContentBackground() - .background( - Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputBackground) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill( - Theme.Colors.textInputStroke - ) + .padding(.horizontal, 10) + .padding(.vertical, 10) + .frame(height: 200) + .hideScrollContentBackground() + .background( + Theme.Shapes.textInputShape + .fill(Theme.Colors.textInputBackground) + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill( + Theme.Colors.textInputStroke + ) + ) + + CheckBoxView(checked: $followPost, + text: postType == .discussion + ? DiscussionLocalization.CreateThread.followDiscussion + : DiscussionLocalization.CreateThread.followQuestion ) - - CheckBoxView(checked: $followPost, - text: postType == .discussion - ? DiscussionLocalization.CreateThread.followDiscussion - : DiscussionLocalization.CreateThread.followQuestion - ) - .padding(.top, 16) - - StyledButton(postType == .discussion - ? DiscussionLocalization.CreateThread.createDiscussion - : DiscussionLocalization.CreateThread.createQuestion, action: { - if postTitle != "" && postBody != "" { - let newThread = DiscussionNewThread(courseID: courseID, - topicID: viewModel.selectedTopic, - type: postType, - title: postTitle, - rawBody: postBody, - followPost: followPost) - Task { - if await viewModel.createNewThread(newThread: newThread) { - onPostCreated() + .padding(.top, 16) + + StyledButton(postType == .discussion + ? DiscussionLocalization.CreateThread.createDiscussion + : DiscussionLocalization.CreateThread.createQuestion, action: { + if postTitle != "" && postBody != "" { + let newThread = DiscussionNewThread(courseID: courseID, + topicID: viewModel.selectedTopic, + type: postType, + title: postTitle, + rawBody: postBody, + followPost: followPost) + Task { + if await viewModel.createNewThread(newThread: newThread) { + onPostCreated() + } } } + }, + isActive: postTitle != "" && postBody != "") + .padding(.top, 26) + Spacer() + }.padding(.horizontal, 24) + .onRightSwipeGesture { + viewModel.router.back() } - }, - isActive: postTitle != "" && postBody != "") - .padding(.top, 26) - Spacer() - }.padding(.horizontal, 24) - .frameLimit() - .onRightSwipeGesture { - viewModel.router.back() - } - }.scrollAvoidKeyboard(dismissKeyboardByTap: true) - } - }.padding(.top, 8) + .frameLimit(width: proxy.size.width) + }.scrollAvoidKeyboard(dismissKeyboardByTap: true) + } + }.padding(.top, 8) + } + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(DiscussionLocalization.CreateThread.newPost) + .background( + Theme.Colors.background + .ignoresSafeArea() + ) } - .navigationBarHidden(false) - .navigationBarBackButtonHidden(false) - .navigationTitle(DiscussionLocalization.CreateThread.newPost) - .background( - Theme.Colors.background - .ignoresSafeArea() - ) } } diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift index 6deb26098..360df3d78 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift @@ -22,129 +22,135 @@ public struct DiscussionSearchTopicsView: View { } public var body: some View { - ZStack(alignment: .top) { - - // MARK: - Page name - VStack(alignment: .center) { - NavigationBar(title: DiscussionLocalization.search, - leftButtonAction: { viewModel.router.backWithFade() }) - .padding(.bottom, -7) - HStack(spacing: 11) { - Image(systemName: "magnifyingglass") - .foregroundColor(Theme.Colors.textPrimary) - .padding(.leading, 16) - .padding(.top, -1) - .foregroundColor( - viewModel.isSearchActive - ? Theme.Colors.accentColor - : Theme.Colors.textPrimary - ) - - TextField( - !viewModel.isSearchActive - ? DiscussionLocalization.search - : "", - text: $viewModel.searchText, - onEditingChanged: { editing in - viewModel.isSearchActive = editing - } - ).focused($focused) - .onAppear { - self.focused = true - } - .foregroundColor(Theme.Colors.textPrimary) - .font(Theme.Fonts.bodyMedium) - Spacer() - if !viewModel.searchText.trimmingCharacters(in: .whitespaces).isEmpty { - Button(action: { viewModel.searchText.removeAll() }, label: { - CoreAssets.clearInput.swiftUIImage - .resizable() - .scaledToFit() - .frame(height: 24) - .padding(.horizontal) - }) - .foregroundColor(Theme.Colors.styledButtonText) - } - } -// .padding(.top, -7) - .frame(minHeight: 48) - .frame(maxWidth: 532) - .background( - Theme.Shapes.textInputShape - .fill(viewModel.isSearchActive - ? Theme.Colors.textInputBackground - : Theme.Colors.textInputUnfocusedBackground) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill(viewModel.isSearchActive - ? Theme.Colors.accentColor - : Theme.Colors.textInputUnfocusedStroke) - ) - .padding(.horizontal, 24) - .padding(.bottom, 20) + GeometryReader { proxy in + ZStack(alignment: .top) { - ZStack { - ScrollView { - HStack { - searchHeader(viewModel: viewModel) - .padding(.horizontal, 24) - .padding(.bottom, 20) - .offset(y: animated ? 0 : 50) - .opacity(animated ? 1 : 0) - Spacer() - }.padding(.leading, 10) + // MARK: - Page name + VStack(alignment: .center) { + NavigationBar(title: DiscussionLocalization.search, + leftButtonAction: { viewModel.router.backWithFade() }) + .padding(.bottom, -7) + + HStack(spacing: 11) { + Image(systemName: "magnifyingglass") + .foregroundColor(Theme.Colors.textPrimary) + .padding(.leading, 16) + .padding(.top, -1) + .foregroundColor( + viewModel.isSearchActive + ? Theme.Colors.accentColor + : Theme.Colors.textPrimary + ) - LazyVStack { - let searchResults = Array(viewModel.searchResults.enumerated()) - ForEach(searchResults, id: \.offset) { index, post in - PostCell(post: post) - .padding(24) - .onAppear { - Task.detached(priority: .high) { - await viewModel.searchCourses( - index: index, - searchTerm: viewModel.searchText - ) + TextField( + !viewModel.isSearchActive + ? DiscussionLocalization.search + : "", + text: $viewModel.searchText, + onEditingChanged: { editing in + viewModel.isSearchActive = editing + } + ).focused($focused) + .onAppear { + self.focused = true + } + .foregroundColor(Theme.Colors.textPrimary) + .font(Theme.Fonts.bodyMedium) + Spacer() + if !viewModel.searchText.trimmingCharacters(in: .whitespaces).isEmpty { + Button(action: { viewModel.searchText.removeAll() }, label: { + CoreAssets.clearInput.swiftUIImage + .resizable() + .scaledToFit() + .frame(height: 24) + .padding(.horizontal) + }) + .foregroundColor(Theme.Colors.styledButtonText) + } + } + // .padding(.top, -7) + .frame(minHeight: 48) + .background( + Theme.Shapes.textInputShape + .fill(viewModel.isSearchActive + ? Theme.Colors.textInputBackground + : Theme.Colors.textInputUnfocusedBackground) + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(viewModel.isSearchActive + ? Theme.Colors.accentColor + : Theme.Colors.textInputUnfocusedStroke) + ) + .frameLimit(width: proxy.size.width) + .padding(.horizontal, 24) + .padding(.bottom, 20) + + ZStack { + ScrollView { + HStack { + searchHeader(viewModel: viewModel) + .padding(.horizontal, 24) + .padding(.bottom, 20) + .offset(y: animated ? 0 : 50) + .opacity(animated ? 1 : 0) + Spacer() + } + .padding(.leading, 10) + .frameLimit(width: proxy.size.width) + + LazyVStack { + let searchResults = Array(viewModel.searchResults.enumerated()) + ForEach(searchResults, id: \.offset) { index, post in + PostCell(post: post) + .padding(24) + .onAppear { + Task.detached(priority: .high) { + await viewModel.searchCourses( + index: index, + searchTerm: viewModel.searchText + ) + } } + if viewModel.searchResults.last != post { + Divider().padding(.horizontal, 24) } - if viewModel.searchResults.last != post { - Divider().padding(.horizontal, 24) + } + Spacer(minLength: 84) + + // MARK: - ProgressBar + if viewModel.fetchInProgress { + VStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 20) + }.frame(maxWidth: .infinity, + maxHeight: .infinity) } } - Spacer(minLength: 84) - - // MARK: - ProgressBar - if viewModel.fetchInProgress { - VStack(alignment: .center) { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 20) - }.frame(maxWidth: .infinity, - maxHeight: .infinity) - } + .id(UUID()) + .frameLimit(width: proxy.size.width) + + Spacer(minLength: 40) } - .id(UUID()) - Spacer(minLength: 40) - }.frameLimit() - } - } - // MARK: - Error Alert - if viewModel.showError { - VStack { - Spacer() - SnackBarView(message: viewModel.errorMessage) + } } - .transition(.move(edge: .bottom)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.errorMessage = nil + // MARK: - Error Alert + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) + } + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } } } } - } - .navigationBarBackButtonHidden(true) - .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + .navigationBarHidden(true) .onAppear { DispatchQueue.main.asyncAfter(deadline: .now()) { withAnimation(.easeIn(duration: 0.3)) { @@ -154,6 +160,7 @@ public struct DiscussionSearchTopicsView: View { } .background(Theme.Colors.background.ignoresSafeArea()) .addTapToEndEditing(isForced: true) + } } private func searchHeader(viewModel: DiscussionSearchTopicsViewModel) -> some View { diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift index 928e4b028..5642e6ecd 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift @@ -51,7 +51,7 @@ public class DiscussionSearchTopicsViewModel: ObservableObject { self.interactor = interactor self.router = router self.debounce = debounce - + cancellable = postStateSubject .receive(on: RunLoop.main) .sink(receiveValue: { [weak self] state in diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift index cee583ec5..c959ed2a9 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift @@ -23,144 +23,147 @@ public struct DiscussionTopicsView: View { } public var body: some View { - ZStack(alignment: .center) { - VStack(alignment: .center) { - // MARK: - Search fake field - HStack(spacing: 11) { - Image(systemName: "magnifyingglass") - .foregroundColor(Theme.Colors.textSecondary) - .padding(.leading, 16) - .padding(.top, 1) - Text(DiscussionLocalization.Topics.search) - .foregroundColor(Theme.Colors.textSecondary) - .font(Theme.Fonts.bodyMedium) - Spacer() - } - .frame(maxWidth: 532) - .frame(minHeight: 48) - .background( - Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputUnfocusedBackground) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill(Theme.Colors.textInputUnfocusedStroke) - ) - .onTapGesture { - viewModel.router.showDiscussionsSearch(courseID: courseID) - } - .padding(.horizontal, 24) - .padding(.top, 10) - .accessibilityElement(children: .ignore) - .accessibilityLabel(DiscussionLocalization.Topics.search) - - // MARK: - Page Body - VStack { - ZStack(alignment: .top) { - RefreshableScrollViewCompat(action: { - await viewModel.getTopics(courseID: self.courseID, withProgress: false) - }) { - VStack { - if let topics = viewModel.discussionTopics { - HStack { - Text(DiscussionLocalization.Topics.mainCategories) - .font(Theme.Fonts.titleMedium) - .foregroundColor(Theme.Colors.textSecondary) - .padding(.horizontal, 24) - .padding(.top, 10) - Spacer() - } - HStack(spacing: 8) { - if let allTopics = topics.first(where: { - $0.name == DiscussionLocalization.Topics.allPosts }) { - Button(action: { - allTopics.action() - }, label: { - VStack { - Spacer(minLength: 0) - CoreAssets.allPosts.swiftUIImage.renderingMode(.template) - .foregroundColor(Theme.Colors.textPrimary) - Text(allTopics.name) - .font(Theme.Fonts.titleSmall) - .foregroundColor(Theme.Colors.textPrimary) - Spacer(minLength: 0) - } - .frame(maxWidth: .infinity) - }).cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground) - .padding(.trailing, -20) + GeometryReader { proxy in + ZStack(alignment: .center) { + VStack(alignment: .center) { + // MARK: - Search fake field + HStack(spacing: 11) { + Image(systemName: "magnifyingglass") + .foregroundColor(Theme.Colors.textSecondary) + .padding(.leading, 16) + .padding(.top, 1) + Text(DiscussionLocalization.Topics.search) + .foregroundColor(Theme.Colors.textSecondary) + .font(Theme.Fonts.bodyMedium) + Spacer() + } + .frame(minHeight: 48) + .background( + Theme.Shapes.textInputShape + .fill(Theme.Colors.textInputUnfocusedBackground) + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(Theme.Colors.textInputUnfocusedStroke) + ) + .onTapGesture { + viewModel.router.showDiscussionsSearch(courseID: courseID) + } + .frameLimit(width: proxy.size.width) + .padding(.horizontal, 24) + .padding(.top, 10) + .accessibilityElement(children: .ignore) + .accessibilityLabel(DiscussionLocalization.Topics.search) + + // MARK: - Page Body + VStack { + ZStack(alignment: .top) { + RefreshableScrollViewCompat(action: { + await viewModel.getTopics(courseID: self.courseID, withProgress: false) + }) { + VStack { + if let topics = viewModel.discussionTopics { + HStack { + Text(DiscussionLocalization.Topics.mainCategories) + .font(Theme.Fonts.titleMedium) + .foregroundColor(Theme.Colors.textSecondary) + .padding(.horizontal, 24) + .padding(.top, 10) + Spacer() } - if let followed = topics.first(where: { - $0.name == DiscussionLocalization.Topics.postImFollowing}) { - Button(action: { - followed.action() - }, label: { - VStack(alignment: .center) { - Spacer(minLength: 0) - CoreAssets.followed.swiftUIImage.renderingMode(.template) - .foregroundColor(Theme.Colors.textPrimary) - Text(followed.name) - .font(Theme.Fonts.titleSmall) - .foregroundColor(Theme.Colors.textPrimary) - Spacer(minLength: 0) + HStack(spacing: 8) { + if let allTopics = topics.first(where: { + $0.name == DiscussionLocalization.Topics.allPosts }) { + Button(action: { + allTopics.action() + }, label: { + VStack { + Spacer(minLength: 0) + CoreAssets.allPosts.swiftUIImage.renderingMode(.template) + .foregroundColor(Theme.Colors.textPrimary) + Text(allTopics.name) + .font(Theme.Fonts.titleSmall) + .foregroundColor(Theme.Colors.textPrimary) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + }).cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground) + .padding(.trailing, -20) + } + if let followed = topics.first(where: { + $0.name == DiscussionLocalization.Topics.postImFollowing}) { + Button(action: { + followed.action() + }, label: { + VStack(alignment: .center) { + Spacer(minLength: 0) + CoreAssets.followed.swiftUIImage.renderingMode(.template) + .foregroundColor(Theme.Colors.textPrimary) + Text(followed.name) + .font(Theme.Fonts.titleSmall) + .foregroundColor(Theme.Colors.textPrimary) + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + }).cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground) + .padding(.leading, -20) + + } + }.padding(.bottom, 16) + ForEach(Array(topics.enumerated()), id: \.offset) { _, topic in + if topic.name != DiscussionLocalization.Topics.allPosts + && topic.name != DiscussionLocalization.Topics.postImFollowing { + + if topic.style == .title { + HStack { + Text("\(topic.name):") + .font(Theme.Fonts.titleMedium) + .foregroundColor(Theme.Colors.textSecondary) + Spacer() + }.padding(.top, 12) + .padding(.bottom, 8) + .padding(.horizontal, 24) + } else { + VStack { + TopicCell(topic: topic) + .padding(.vertical, 10) + Divider() + }.padding(.horizontal, 24) } - .frame(maxWidth: .infinity) - }).cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground) - .padding(.leading, -20) - - } - }.padding(.bottom, 16) - ForEach(Array(topics.enumerated()), id: \.offset) { _, topic in - if topic.name != DiscussionLocalization.Topics.allPosts - && topic.name != DiscussionLocalization.Topics.postImFollowing { - - if topic.style == .title { - HStack { - Text("\(topic.name):") - .font(Theme.Fonts.titleMedium) - .foregroundColor(Theme.Colors.textSecondary) - Spacer() - }.padding(.top, 12) - .padding(.bottom, 8) - .padding(.horizontal, 24) - } else { - VStack { - TopicCell(topic: topic) - .padding(.vertical, 10) - Divider() - }.padding(.horizontal, 24) } } + } - + Spacer(minLength: 84) } - Spacer(minLength: 84) + .frameLimit(width: proxy.size.width) } - }.frameLimit() .onRightSwipeGesture { router.back() } - - } - }.frame(maxWidth: .infinity) - }.padding(.top, 8) - if viewModel.isShowProgress { - ProgressBar(size: 40, lineWidth: 8) - .padding(.horizontal) + + } + }.frame(maxWidth: .infinity) + }.padding(.top, 8) + if viewModel.isShowProgress { + ProgressBar(size: 40, lineWidth: 8) + .padding(.horizontal) + } } - } - .onFirstAppear { - Task { - await viewModel.getTopics(courseID: courseID) + .onFirstAppear { + Task { + await viewModel.getTopics(courseID: courseID) + } } + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(viewModel.title) + .background( + Theme.Colors.background + .ignoresSafeArea() + ) } - .navigationBarHidden(false) - .navigationBarBackButtonHidden(false) - .navigationTitle(viewModel.title) - .background( - Theme.Colors.background - .ignoresSafeArea() - ) } } diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index 7f5e14bad..86ce06e39 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -64,34 +64,37 @@ public struct PostsView: View { } public var body: some View { - ZStack(alignment: .top) { - // MARK: - Page Body - ScrollViewReader { scroll in - VStack { - ZStack(alignment: .top) { - VStack { + GeometryReader { proxy in + ZStack(alignment: .top) { + // MARK: - Page Body + ScrollViewReader { scroll in + VStack { + ZStack(alignment: .top) { VStack { - HStack { - Group { - Button(action: { - viewModel.generateButtons(type: .filter) - showingAlert = true - }, label: { - CoreAssets.filter.swiftUIImage.renderingMode(.template) - .foregroundColor(Theme.Colors.accentXColor) - Text(viewModel.filterTitle.localizedValue) - }) - Spacer() - Button(action: { - viewModel.generateButtons(type: .sort) - showingAlert = true - }, label: { - CoreAssets.sort.swiftUIImage.renderingMode(.template) - .foregroundColor(Theme.Colors.accentXColor) - Text(viewModel.sortTitle.localizedValue) - }) - }.foregroundColor(Theme.Colors.accentColor) - } .font(Theme.Fonts.labelMedium) + VStack { + HStack { + Group { + Button(action: { + viewModel.generateButtons(type: .filter) + showingAlert = true + }, label: { + CoreAssets.filter.swiftUIImage.renderingMode(.template) + .foregroundColor(Theme.Colors.accentXColor) + Text(viewModel.filterTitle.localizedValue) + }) + Spacer() + Button(action: { + viewModel.generateButtons(type: .sort) + showingAlert = true + }, label: { + CoreAssets.sort.swiftUIImage.renderingMode(.template) + .foregroundColor(Theme.Colors.accentXColor) + Text(viewModel.sortTitle.localizedValue) + }) + } + .foregroundColor(Theme.Colors.accentColor) + } + .font(Theme.Fonts.labelMedium) .padding(.horizontal, 24) .padding(.vertical, 12) .shadow(color: Theme.Colors.shadowColor, @@ -99,146 +102,151 @@ public struct PostsView: View { .background( Theme.Colors.background ) - Divider().offset(y: -8) - } - .frameLimit() - RefreshableScrollViewCompat(action: { - viewModel.resetPosts() - _ = await viewModel.getPosts( - pageNumber: 1, - withProgress: false - ) - }) { - let posts = Array(viewModel.filteredPosts.enumerated()) - if posts.count >= 1 { - LazyVStack { - VStack {}.frame(height: 1) - .id(1) - HStack(alignment: .center) { - Text(title) - .font(Theme.Fonts.titleLarge) - .foregroundColor(Theme.Colors.textPrimary) - Spacer() - Button(action: { - router.createNewThread( - courseID: courseID, - selectedTopic: currentBlockID, - onPostCreated: { - reloadPage(onSuccess: { - withAnimation { - scroll.scrollTo(1) - } + .frameLimit(width: proxy.size.width) + Divider().offset(y: -8) + } + + RefreshableScrollViewCompat(action: { + viewModel.resetPosts() + _ = await viewModel.getPosts( + pageNumber: 1, + withProgress: false + ) + }) { + let posts = Array(viewModel.filteredPosts.enumerated()) + if posts.count >= 1 { + LazyVStack { + VStack {}.frame(height: 1) + .id(1) + HStack(alignment: .center) { + Text(title) + .font(Theme.Fonts.titleLarge) + .foregroundColor(Theme.Colors.textPrimary) + Spacer() + Button(action: { + router.createNewThread( + courseID: courseID, + selectedTopic: currentBlockID, + onPostCreated: { + reloadPage(onSuccess: { + withAnimation { + scroll.scrollTo(1) + } + }) }) - }) - }, label: { - VStack { - CoreAssets.addComment.swiftUIImage - .font(Theme.Fonts.labelLarge) - .padding(6) - } - .foregroundColor(Theme.Colors.white) - .background( - Circle() - .foregroundColor(Theme.Colors.accentButtonColor) - ) - }) - } - .padding(.horizontal, 24) - - ForEach(posts, id: \.offset) { index, post in - PostCell(post: post) - .padding(.horizontal, 24) - .padding(.vertical, 10) - .id(UUID()) - .onAppear { - Task { - await viewModel.getPostsPagination( - index: index - ) + }, label: { + VStack { + CoreAssets.addComment.swiftUIImage + .font(Theme.Fonts.labelLarge) + .padding(6) } + .foregroundColor(Theme.Colors.white) + .background( + Circle() + .foregroundColor(Theme.Colors.accentButtonColor) + ) + }) + } + .padding(.horizontal, 24) + + ForEach(posts, id: \.offset) { index, post in + PostCell(post: post) + .padding(.horizontal, 24) + .padding(.vertical, 10) + .id(UUID()) + .onAppear { + Task { + await viewModel.getPostsPagination( + index: index + ) + } + } + if posts.last?.element != post { + Divider().padding(.horizontal, 24) } - if posts.last?.element != post { - Divider().padding(.horizontal, 24) } + Spacer(minLength: 84) } - Spacer(minLength: 84) - } - } else { - if !viewModel.fetchInProgress { - VStack(spacing: 0) { - CoreAssets.discussionIcon.swiftUIImage - .renderingMode(.template) - .foregroundColor(Theme.Colors.textPrimary) - Text(DiscussionLocalization.Posts.NoDiscussion.title) - .font(Theme.Fonts.titleLarge) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .padding(.top, 40) - Text(DiscussionLocalization.Posts.NoDiscussion.description) - .font(Theme.Fonts.bodyLarge) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .padding(.top, 12) - StyledButton( - DiscussionLocalization.Posts.NoDiscussion.createbutton, - action: { - router.createNewThread(courseID: courseID, - selectedTopic: currentBlockID, - onPostCreated: { - reloadPage(onSuccess: { - withAnimation { - scroll.scrollTo(1) - } + .frameLimit(width: proxy.size.width) + } else { + if !viewModel.fetchInProgress { + VStack(spacing: 0) { + CoreAssets.discussionIcon.swiftUIImage + .renderingMode(.template) + .foregroundColor(Theme.Colors.textPrimary) + Text(DiscussionLocalization.Posts.NoDiscussion.title) + .font(Theme.Fonts.titleLarge) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 40) + Text(DiscussionLocalization.Posts.NoDiscussion.description) + .font(Theme.Fonts.bodyLarge) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 12) + StyledButton( + DiscussionLocalization.Posts.NoDiscussion.createbutton, + action: { + router.createNewThread(courseID: courseID, + selectedTopic: currentBlockID, + onPostCreated: { + reloadPage(onSuccess: { + withAnimation { + scroll.scrollTo(1) + } + }) }) - }) - }, - isTransparent: true) - .frame(width: 215) - .padding(.top, 40) - .colorMultiply(Theme.Colors.accentColor) - - }.padding(24) + }, + isTransparent: true) + .frame(width: 215) + .padding(.top, 40) + .colorMultiply(Theme.Colors.accentColor) + + } + .padding(24) .padding(.top, 100) + .frameLimit(width: proxy.size.width) + } } } } - }.accessibilityAction {} - .frameLimit() + .accessibilityAction {} .animation(nil) .onRightSwipeGesture { router.back() } - } - }.frame(maxWidth: .infinity) - } - .padding(.top, 8) - if viewModel.isShowProgress { - VStack(alignment: .center) { - ProgressBar(size: 40, lineWidth: 8) - .padding(.horizontal) - }.frame(maxWidth: .infinity, - maxHeight: .infinity) + } + }.frame(maxWidth: .infinity) + } + .padding(.top, 8) + if viewModel.isShowProgress { + VStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.horizontal) + }.frame(maxWidth: .infinity, + maxHeight: .infinity) + } } - } - .onFirstAppear { - Task { - await viewModel.getPosts( - pageNumber: 1, - withProgress: true - ) + .onFirstAppear { + Task { + await viewModel.getPosts( + pageNumber: 1, + withProgress: true + ) + } } + .navigationBarHidden(!showTopMenu) + .navigationBarBackButtonHidden(!showTopMenu) + .navigationTitle(title) + .background( + Theme.Colors.background + .ignoresSafeArea() + ) + // MARK: - Action Sheet + .actionSheet(isPresented: $showingAlert, content: { + ActionSheet(title: Text(DiscussionLocalization.Posts.Alert.makeSelection), buttons: viewModel.filterButtons) + }) } - .navigationBarHidden(!showTopMenu) - .navigationBarBackButtonHidden(!showTopMenu) - .navigationTitle(title) - .background( - Theme.Colors.background - .ignoresSafeArea() - ) - // MARK: - Action Sheet - .actionSheet(isPresented: $showingAlert, content: { - ActionSheet(title: Text(DiscussionLocalization.Posts.Alert.makeSelection), buttons: viewModel.filterButtons) - }) } @MainActor diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 09d7b9c32..cedc3842e 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -338,7 +338,7 @@ 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)! ) diff --git a/OpenEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift index fa4c7c4f8..f51ebc476 100644 --- a/OpenEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -35,7 +35,7 @@ struct MainScreenView: View { for: .normal ) } - + var body: some View { TabView(selection: $viewModel.selection) { let config = Container.shared.resolve(ConfigProtocol.self) diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift index ab1d61148..105715367 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift @@ -19,150 +19,152 @@ public struct DeleteAccountView: View { } public var body: some View { - ZStack(alignment: .top) { - // MARK: - Page Body - ScrollView { - VStack { - Group { - ZStack { - CoreAssets.bgDelete.swiftUIImage - CoreAssets.deleteChar.swiftUIImage - .foregroundColor(Theme.Colors.accentXColor) - .offset(y: -31) - CoreAssets.deleteEyes.swiftUIImage - .offset(x: -7, y: -27) - .accessibilityIdentifier("delete_account_image") - }.padding(.top, 50) + GeometryReader { proxy in + ZStack(alignment: .top) { + // MARK: - Page Body + ScrollView { + VStack { + Group { + ZStack { + CoreAssets.bgDelete.swiftUIImage + CoreAssets.deleteChar.swiftUIImage + .foregroundColor(Theme.Colors.accentXColor) + .offset(y: -31) + CoreAssets.deleteEyes.swiftUIImage + .offset(x: -7, y: -27) + .accessibilityIdentifier("delete_account_image") + }.padding(.top, 50) + + HStack { + Text(ProfileLocalization.DeleteAccount.areYouSure) + .foregroundColor(Theme.Colors.navigationBarTintColor) + + Text(ProfileLocalization.DeleteAccount.wantToDelete) + .foregroundColor(Theme.Colors.alert) + } + .accessibilityIdentifier("are_you_sure_text") + + }.multilineTextAlignment(.center) + .font(Theme.Fonts.headlineSmall) - HStack { - Text(ProfileLocalization.DeleteAccount.areYouSure) - .foregroundColor(Theme.Colors.navigationBarTintColor) - + Text(ProfileLocalization.DeleteAccount.wantToDelete) - .foregroundColor(Theme.Colors.alert) - } - .accessibilityIdentifier("are_you_sure_text") - - }.multilineTextAlignment(.center) - .font(Theme.Fonts.headlineSmall) - - Text(ProfileLocalization.DeleteAccount.description) - .foregroundColor(Theme.Colors.textSecondary) - .font(Theme.Fonts.labelLarge) - .multilineTextAlignment(.center) - .padding(.top, 16) - .accessibilityIdentifier("delete_account_description_text") - - // MARK: Password - Group { - Text(ProfileLocalization.DeleteAccount.password) + Text(ProfileLocalization.DeleteAccount.description) .foregroundColor(Theme.Colors.textSecondary) .font(Theme.Fonts.labelLarge) - .multilineTextAlignment(.leading) + .multilineTextAlignment(.center) .padding(.top, 16) - .accessibilityIdentifier("password_text") + .accessibilityIdentifier("delete_account_description_text") - HStack(spacing: 11) { - SecureField(ProfileLocalization.DeleteAccount.passwordDescription, - text: $viewModel.password) + // MARK: Password + Group { + Text(ProfileLocalization.DeleteAccount.password) + .foregroundColor(Theme.Colors.textSecondary) + .font(Theme.Fonts.labelLarge) + .multilineTextAlignment(.leading) + .padding(.top, 16) + .accessibilityIdentifier("password_text") + + HStack(spacing: 11) { + SecureField(ProfileLocalization.DeleteAccount.passwordDescription, + text: $viewModel.password) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("password_textfield") + } + .padding(.horizontal, 14) + .frame(minHeight: 48) + .frame(maxWidth: .infinity) + .background( + Theme.Shapes.textInputShape + .fill(Theme.Colors.textInputBackground) + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(Theme.Colors.textInputUnfocusedStroke) + ) + Text(viewModel.incorrectPassword + ? ProfileLocalization.DeleteAccount.incorrectPassword + : " ") + .foregroundColor(Theme.Colors.alert) .font(Theme.Fonts.labelLarge) - .foregroundColor(Theme.Colors.textPrimary) - .accessibilityIdentifier("password_textfield") + .multilineTextAlignment(.leading) + .padding(.top, 0) + .shake($viewModel.incorrectPassword, + onCompletion: { viewModel.incorrectPassword.toggle() }) + .accessibilityIdentifier("incorrect_password_text") + + }.frame(minWidth: 0, + maxWidth: .infinity, + alignment: .topLeading) + + // MARK: Comfirmation button + if viewModel.isShowProgress { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 20) + .padding(.horizontal) + .accessibilityIdentifier("progressbar") + } else { + StyledButton( + ProfileLocalization.DeleteAccount.comfirm, + action: { + Task { + try await viewModel.deleteAccount(password: viewModel.password) + } + }, + color: .clear, + textColor: Theme.Colors.alert, + borderColor: Theme.Colors.alert, + isActive: viewModel.password.count >= 2 + ) + .padding(.top, 18) + .accessibilityIdentifier("delete_account_button") } - .padding(.horizontal, 14) - .frame(minHeight: 48) - .frame(maxWidth: .infinity) - .background( - Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputBackground) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill(Theme.Colors.textInputUnfocusedStroke) - ) - Text(viewModel.incorrectPassword - ? ProfileLocalization.DeleteAccount.incorrectPassword - : " ") - .foregroundColor(Theme.Colors.alert) - .font(Theme.Fonts.labelLarge) - .multilineTextAlignment(.leading) - .padding(.top, 0) - .shake($viewModel.incorrectPassword, - onCompletion: { viewModel.incorrectPassword.toggle() }) - .accessibilityIdentifier("incorrect_password_text") - }.frame(minWidth: 0, - maxWidth: .infinity, - alignment: .topLeading) - - // MARK: Comfirmation button - if viewModel.isShowProgress { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 20) - .padding(.horizontal) - .accessibilityIdentifier("progressbar") - } else { + // MARK: Back to profile StyledButton( - ProfileLocalization.DeleteAccount.comfirm, + ProfileLocalization.DeleteAccount.backToProfile, action: { - Task { - try await viewModel.deleteAccount(password: viewModel.password) - } + viewModel.router.back() }, - color: .clear, - textColor: Theme.Colors.alert, - borderColor: Theme.Colors.alert, - isActive: viewModel.password.count >= 2 + color: Theme.Colors.accentColor, + textColor: Theme.Colors.primaryButtonTextColor, + iconImage: CoreAssets.arrowLeft.swiftUIImage, + iconPosition: .left ) - .padding(.top, 18) - .accessibilityIdentifier("delete_account_button") + .padding(.top, 35) + .accessibilityIdentifier("back_button") } - - // MARK: Back to profile - StyledButton( - ProfileLocalization.DeleteAccount.backToProfile, - action: { - viewModel.router.back() - }, - color: Theme.Colors.accentColor, - textColor: Theme.Colors.primaryButtonTextColor, - iconImage: CoreAssets.arrowLeft.swiftUIImage, - iconPosition: .left - ) - .padding(.top, 35) - .accessibilityIdentifier("back_button") + .frameLimit(width: proxy.size.width) } - }.padding(.horizontal, 24) + .padding(.horizontal, 24) .frame(minHeight: 0, maxHeight: .infinity, alignment: .top) - .frameLimit(sizePortrait: 420) - .padding(.top, 8) .navigationBarHidden(false) .navigationBarBackButtonHidden(false) .navigationTitle(ProfileLocalization.DeleteAccount.title) - - // MARK: - Error Alert - if viewModel.showError { - VStack { - Spacer() - SnackBarView(message: viewModel.errorMessage) - } - .padding(.bottom, viewModel.connectivity.isInternetAvaliable - ? 0 : OfflineSnackBarView.height) - .transition(.move(edge: .bottom)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.errorMessage = nil + + // MARK: - Error Alert + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) + } + .padding(.bottom, viewModel.connectivity.isInternetAvaliable + ? 0 : OfflineSnackBarView.height) + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } } } } + .background( + Theme.Colors.background + .ignoresSafeArea() + ) } - .background( - Theme.Colors.background - .ignoresSafeArea() - ) } } diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift index e4dec0b33..b2467d82c 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift @@ -28,116 +28,120 @@ public struct EditProfileView: View { } public var body: some View { - ZStack(alignment: .top) { - // MARK: - Page Body - ScrollView { - VStack { - Text(viewModel.profileChanges.profileType.localizedValue.capitalized) - .font(Theme.Fonts.titleSmall) - .foregroundColor(Theme.Colors.textSecondary) - .accessibilityIdentifier("profile_type_text") - Button(action: { - withAnimation { - showingBottomSheet.toggle() - } - }, label: { - UserAvatar(url: viewModel.userModel.avatarUrl, image: $viewModel.inputImage) - .padding(.top, 30) - .overlay( - ZStack { - Circle().frame(width: 36, height: 36) - .foregroundColor(Theme.Colors.accentXColor) - CoreAssets.addPhoto.swiftUIImage.renderingMode(.template) - .foregroundColor(Theme.Colors.primaryButtonTextColor) - }.offset(x: 36, y: 50) + GeometryReader { proxy in + ZStack(alignment: .top) { + // MARK: - Page Body + ScrollView { + VStack { + Text(viewModel.profileChanges.profileType.localizedValue.capitalized) + .font(Theme.Fonts.titleSmall) + .foregroundColor(Theme.Colors.textSecondary) + .accessibilityIdentifier("profile_type_text") + Button(action: { + withAnimation { + showingBottomSheet.toggle() + } + }, label: { + UserAvatar(url: viewModel.userModel.avatarUrl, image: $viewModel.inputImage) + .padding(.top, 30) + .overlay( + ZStack { + Circle().frame(width: 36, height: 36) + .foregroundColor(Theme.Colors.accentXColor) + CoreAssets.addPhoto.swiftUIImage.renderingMode(.template) + .foregroundColor(Theme.Colors.primaryButtonTextColor) + }.offset(x: 36, y: 50) + ) + }) + .accessibilityIdentifier("change_profile_image_button") + + Text(viewModel.userModel.name) + .font(Theme.Fonts.headlineSmall) + .accessibilityIdentifier("username_text") + + Button(ProfileLocalization.switchTo + " " + + viewModel.profileChanges.profileType.switchToButtonTitle, + action: { + viewModel.switchProfile() + }) + .padding(.vertical, 24) + .font(Theme.Fonts.labelLarge) + .accessibilityIdentifier("switch_profile_button") + + Group { + PickerView( + config: viewModel.yearsConfiguration, + router: viewModel.router ) - }) - .accessibilityIdentifier("change_profile_image_button") - - Text(viewModel.userModel.name) - .font(Theme.Fonts.headlineSmall) - .accessibilityIdentifier("username_text") - - Button(ProfileLocalization.switchTo + " " + - viewModel.profileChanges.profileType.switchToButtonTitle, - action: { - viewModel.switchProfile() - }) - .padding(.vertical, 24) - .font(Theme.Fonts.labelLarge) - .accessibilityIdentifier("switch_profile_button") - - Group { - PickerView( - config: viewModel.yearsConfiguration, - router: viewModel.router - ) - if viewModel.isEditable { - VStack(alignment: .leading) { - PickerView(config: viewModel.countriesConfiguration, - router: viewModel.router) - - PickerView(config: viewModel.spokenLanguageConfiguration, - router: viewModel.router) - - Text(ProfileLocalization.Edit.Fields.aboutMe) - .font(Theme.Fonts.titleMedium) - .accessibilityIdentifier("about_text") - TextEditor(text: $viewModel.profileChanges.shortBiography) - .font(Theme.Fonts.bodyMedium) - .foregroundColor(Theme.Colors.textPrimary) - .padding(.horizontal, 12) - .padding(.vertical, 4) - .frame(height: 200) - .hideScrollContentBackground() - .background( - Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputBackground) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill( - Theme.Colors.textInputStroke - ) - ) - .accessibilityIdentifier("short_bio_textarea") + if viewModel.isEditable { + VStack(alignment: .leading) { + PickerView(config: viewModel.countriesConfiguration, + router: viewModel.router) + + PickerView(config: viewModel.spokenLanguageConfiguration, + router: viewModel.router) + + Text(ProfileLocalization.Edit.Fields.aboutMe) + .font(Theme.Fonts.titleMedium) + .accessibilityIdentifier("about_text") + TextEditor(text: $viewModel.profileChanges.shortBiography) + .font(Theme.Fonts.bodyMedium) + .foregroundColor(Theme.Colors.textPrimary) + .padding(.horizontal, 12) + .padding(.vertical, 4) + .frame(height: 200) + .hideScrollContentBackground() + .background( + Theme.Shapes.textInputShape + .fill(Theme.Colors.textInputBackground) + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill( + Theme.Colors.textInputStroke + ) + ) + .accessibilityIdentifier("short_bio_textarea") + } } } - } - .onReceive(viewModel.yearsConfiguration.$text - .combineLatest(viewModel.countriesConfiguration.$text, - viewModel.spokenLanguageConfiguration.$text), - perform: { _ in - viewModel.checkChanges() - viewModel.checkProfileType() - }) - .onChange(of: viewModel.profileChanges) { _ in - viewModel.checkChanges() - viewModel.checkProfileType() - } - .onChange(of: viewModel.profileChanges.shortBiography, perform: { bio in - if bio.count > 300 { - viewModel.profileChanges.shortBiography.removeLast() + .onReceive(viewModel.yearsConfiguration.$text + .combineLatest(viewModel.countriesConfiguration.$text, + viewModel.spokenLanguageConfiguration.$text), + perform: { _ in + viewModel.checkChanges() + viewModel.checkProfileType() + }) + .onChange(of: viewModel.profileChanges) { _ in + viewModel.checkChanges() + viewModel.checkProfileType() } - }) - - Button(ProfileLocalization.Edit.deleteAccount, action: { - viewModel.trackProfileDeleteAccountClicked() - viewModel.router.showDeleteProfileView() - }) - .font(Theme.Fonts.labelLarge) - .foregroundColor(Theme.Colors.alert) - .padding(.top, 44) - .accessibilityIdentifier("delete_account_button") - - Spacer(minLength: 84) - }.padding(.horizontal, 24) + .onChange(of: viewModel.profileChanges.shortBiography, perform: { bio in + if bio.count > 300 { + viewModel.profileChanges.shortBiography.removeLast() + } + }) + + Button(ProfileLocalization.Edit.deleteAccount, action: { + viewModel.trackProfileDeleteAccountClicked() + viewModel.router.showDeleteProfileView() + }) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.alert) + .padding(.top, 44) + .accessibilityIdentifier("delete_account_button") + + Spacer(minLength: 84) + } + .padding(.horizontal, 24) .sheet(isPresented: $showingImagePicker) { ImagePickerView(image: $viewModel.inputImage) .ignoresSafeArea() } - }.padding(.top, 8) + .frameLimit(width: proxy.size.width) + } + .padding(.top, 8) .onChange(of: showingImagePicker, perform: { value in if !value { if let image = viewModel.inputImage { @@ -150,97 +154,97 @@ public struct EditProfileView: View { viewModel.backButtonTapped() } .scrollAvoidKeyboard(dismissKeyboardByTap: true) - .frameLimit(sizePortrait: 420) .ignoresSafeArea(edges: .bottom) - // MARK: - Error Alert - if viewModel.showError { - VStack { - Spacer() - SnackBarView(message: viewModel.errorMessage) - } - .transition(.move(edge: .bottom)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.errorMessage = nil + // MARK: - Error Alert + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) } - } - } - // MARK: - Alert - if viewModel.showAlert { - VStack(alignment: .center) { - Spacer() - HStack(alignment: .top, spacing: 6) { - CoreAssets.alarm.swiftUIImage.renderingMode(.template) - Text(viewModel.alertMessage ?? "") - }.shadowCardStyle(bgColor: Theme.Colors.warning, - textColor: .black) .transition(.move(edge: .bottom)) .onAppear { doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.alertMessage = nil + viewModel.errorMessage = nil } } } - } - ProfileBottomSheet( - showingBottomSheet: $showingBottomSheet, - openGallery: { - showingImagePicker = true - withAnimation { - showingBottomSheet = false + // MARK: - Alert + if viewModel.showAlert { + VStack(alignment: .center) { + Spacer() + HStack(alignment: .top, spacing: 6) { + CoreAssets.alarm.swiftUIImage.renderingMode(.template) + Text(viewModel.alertMessage ?? "") + }.shadowCardStyle(bgColor: Theme.Colors.warning, + textColor: .black) + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.alertMessage = nil + } + } } - }, - removePhoto: { - viewModel.inputImage = CoreAssets.noAvatar.image - viewModel.profileChanges.isAvatarDeleted = true - showingBottomSheet = false - }) - - if viewModel.isShowProgress { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 150) - .padding(.horizontal) - .accessibilityIdentifier("progressbar") + } + ProfileBottomSheet( + showingBottomSheet: $showingBottomSheet, + openGallery: { + showingImagePicker = true + withAnimation { + showingBottomSheet = false + } + }, + removePhoto: { + viewModel.inputImage = CoreAssets.noAvatar.image + viewModel.profileChanges.isAvatarDeleted = true + showingBottomSheet = false + }) + + if viewModel.isShowProgress { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 150) + .padding(.horizontal) + .accessibilityIdentifier("progressbar") + } } - } - .navigationBarHidden(false) - .navigationBarBackButtonHidden(true) - .navigationTitle(ProfileLocalization.editProfile) - .toolbar { - ToolbarItem(placement: .navigationBarLeading, content: { - Button(action: { - viewModel.backButtonTapped() - }, label: { - CoreAssets.arrowLeft.swiftUIImage - .renderingMode(.template) - .foregroundColor(Theme.Colors.accentColor) + .navigationBarHidden(false) + .navigationBarBackButtonHidden(true) + .navigationTitle(ProfileLocalization.editProfile) + .toolbar { + ToolbarItem(placement: .navigationBarLeading, content: { + Button(action: { + viewModel.backButtonTapped() + }, label: { + CoreAssets.arrowLeft.swiftUIImage + .renderingMode(.template) + .foregroundColor(Theme.Colors.accentColor) + }) }) - }) - ToolbarItem(placement: .navigationBarTrailing, content: { - Button(action: { - if viewModel.isChanged { - Task { - viewModel.trackProfileEditDoneClicked() - await viewModel.saveProfileUpdates() + ToolbarItem(placement: .navigationBarTrailing, content: { + Button(action: { + if viewModel.isChanged { + Task { + viewModel.trackProfileEditDoneClicked() + await viewModel.saveProfileUpdates() + } } - } - }, label: { - HStack(spacing: 2) { - CoreAssets.done.swiftUIImage.renderingMode(.template) - .foregroundColor(Theme.Colors.accentXColor) - Text(CoreLocalization.done) - .font(Theme.Fonts.labelLarge) - .foregroundColor(Theme.Colors.accentXColor) - } + }, label: { + HStack(spacing: 2) { + CoreAssets.done.swiftUIImage.renderingMode(.template) + .foregroundColor(Theme.Colors.accentXColor) + Text(CoreLocalization.done) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.accentXColor) + } + }) + .opacity(viewModel.isChanged ? 1 : 0.3) + .accessibilityIdentifier("done_button") }) - .opacity(viewModel.isChanged ? 1 : 0.3) - .accessibilityIdentifier("done_button") - }) + } + .background( + Theme.Colors.background + .ignoresSafeArea() + ) } - .background( - Theme.Colors.background - .ignoresSafeArea() - ) } } diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index f803e6a94..03c920b2f 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -21,75 +21,79 @@ public struct ProfileView: View { } public var body: some View { - ZStack(alignment: .top) { - // MARK: - Page Body - RefreshableScrollViewCompat( - action: { - await viewModel.getMyProfile(withProgress: false) - }, - content: content - ) - .accessibilityAction {} - .frameLimit(sizePortrait: 420) - .padding(.top, 8) - .onChange(of: settingsTapped, perform: { _ in - 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 - } + GeometryReader { proxy in + ZStack(alignment: .top) { + // MARK: - Page Body + RefreshableScrollViewCompat( + action: { + await viewModel.getMyProfile(withProgress: false) + }, + content: { + content + .frameLimit(width: proxy.size.width) } ) - }) - .navigationBarHidden(false) - .navigationBarBackButtonHidden(false) - - // MARK: - Offline mode SnackBar - OfflineSnackBarView( - connectivity: viewModel.connectivity, - reloadAction: { - await viewModel.getMyProfile(withProgress: false) - } - ) - - // MARK: - Error Alert - if viewModel.showError { - VStack { - Spacer() - SnackBarView(message: viewModel.errorMessage) - } - .padding( - .bottom, - viewModel.connectivity.isInternetAvaliable - ? 0 : OfflineSnackBarView.height + .accessibilityAction {} + .padding(.top, 8) + .onChange(of: settingsTapped, perform: { _ in + 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) + + // MARK: - Offline mode SnackBar + OfflineSnackBarView( + connectivity: viewModel.connectivity, + reloadAction: { + await viewModel.getMyProfile(withProgress: false) + } ) - .transition(.move(edge: .bottom)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.errorMessage = nil + + // MARK: - Error Alert + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) + } + .padding( + .bottom, + viewModel.connectivity.isInternetAvaliable + ? 0 : OfflineSnackBarView.height + ) + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } } } } - } - .onFirstAppear { - Task { - await viewModel.getMyProfile() + .onFirstAppear { + Task { + await viewModel.getMyProfile() + } } - } - .background( - Theme.Colors.background - .ignoresSafeArea() - ) - .onReceive(NotificationCenter.default.publisher(for: .profileUpdated)) { _ in - Task { - await viewModel.getMyProfile() + .background( + Theme.Colors.background + .ignoresSafeArea() + ) + .onReceive(NotificationCenter.default.publisher(for: .profileUpdated)) { _ in + Task { + await viewModel.getMyProfile() + } } } } @@ -100,7 +104,7 @@ public struct ProfileView: View { .padding(.horizontal) } - private func content() -> some View { + private var content: some View { VStack { if viewModel.isShowProgress { ProgressBar(size: 40, lineWidth: 8) diff --git a/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift index fb9718b20..38078a834 100644 --- a/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift +++ b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift @@ -23,86 +23,89 @@ 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) - }) { - VStack { - if viewModel.isShowProgress { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 200) - .padding(.horizontal) - } else { - ProfileAvatar(url: viewModel.userModel?.avatarUrl ?? "") - if let name = viewModel.userModel?.name, name != "" { - Text(name) - .font(Theme.Fonts.headlineSmall) - .padding(.top, 20) - } - Text("@\(viewModel.userModel?.username ?? "")") - .font(Theme.Fonts.labelLarge) - .padding(.top, 4) - .foregroundColor(Theme.Colors.textSecondary) - .padding(.bottom, 10) - - // MARK: - Profile Info - if viewModel.userModel?.yearOfBirth != 0 || viewModel.userModel?.shortBiography != "" { - VStack(alignment: .leading, spacing: 14) { - Text(ProfileLocalization.info) - .padding(.horizontal, 24) - .font(Theme.Fonts.labelLarge) - .foregroundColor(Theme.Colors.textSecondary) - - VStack(alignment: .leading, spacing: 16) { - if viewModel.userModel?.yearOfBirth != 0 { - HStack { - Text(ProfileLocalization.Edit.Fields.yearOfBirth) - .foregroundColor(Theme.Colors.textSecondary) - Text(String(viewModel.userModel?.yearOfBirth ?? 0)) + GeometryReader { proxy in + ZStack(alignment: .top) { + Theme.Colors.background + .ignoresSafeArea() + // MARK: - Page Body + RefreshableScrollViewCompat(action: { + await viewModel.getUserProfile(withProgress: false) + }) { + VStack { + if viewModel.isShowProgress { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 200) + .padding(.horizontal) + } else { + ProfileAvatar(url: viewModel.userModel?.avatarUrl ?? "") + if let name = viewModel.userModel?.name, name != "" { + Text(name) + .font(Theme.Fonts.headlineSmall) + .padding(.top, 20) + } + Text("@\(viewModel.userModel?.username ?? "")") + .font(Theme.Fonts.labelLarge) + .padding(.top, 4) + .foregroundColor(Theme.Colors.textSecondary) + .padding(.bottom, 10) + + // MARK: - Profile Info + if viewModel.userModel?.yearOfBirth != 0 || viewModel.userModel?.shortBiography != "" { + VStack(alignment: .leading, spacing: 14) { + Text(ProfileLocalization.info) + .padding(.horizontal, 24) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textSecondary) + + VStack(alignment: .leading, spacing: 16) { + if viewModel.userModel?.yearOfBirth != 0 { + HStack { + Text(ProfileLocalization.Edit.Fields.yearOfBirth) + .foregroundColor(Theme.Colors.textSecondary) + Text(String(viewModel.userModel?.yearOfBirth ?? 0)) + } } - } - if let bio = viewModel.userModel?.shortBiography, bio != "" { - HStack(alignment: .top) { - Text(ProfileLocalization.bio + " ") - .foregroundColor(Theme.Colors.textSecondary) - + Text(bio) + if let bio = viewModel.userModel?.shortBiography, bio != "" { + HStack(alignment: .top) { + Text(ProfileLocalization.bio + " ") + .foregroundColor(Theme.Colors.textSecondary) + + Text(bio) + } } } - } - .cardStyle( - bgColor: Theme.Colors.textInputUnfocusedBackground, - strokeColor: .clear - ) - }.padding(.bottom, 16) + .cardStyle( + bgColor: Theme.Colors.textInputUnfocusedBackground, + strokeColor: .clear + ) + }.padding(.bottom, 16) + } } + Spacer() } - Spacer() + .frameLimit(width: proxy.size.width) } - }.frameLimit(sizePortrait: 420) .padding(.top, 8) .navigationBarHidden(false) .navigationBarBackButtonHidden(false) - - // MARK: - Error Alert - if viewModel.showError { - VStack { - Spacer() - SnackBarView(message: viewModel.errorMessage) - } - .transition(.move(edge: .bottom)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.errorMessage = nil + + // MARK: - Error Alert + 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.getUserProfile() + .onFirstAppear { + Task { + await viewModel.getUserProfile() + } } } .sheetNavigation(isSheet: isSheet) { diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index d3ad59f26..20b6f1e1e 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -20,101 +20,104 @@ public struct SettingsView: View { } public var body: some View { - ZStack(alignment: .top) { - - // MARK: - Page Body - ScrollView { - VStack(alignment: .leading, spacing: 24) { - if viewModel.isShowProgress { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 200) - .padding(.horizontal) - .accessibilityIdentifier("progressbar") - } else { - // MARK: Wi-fi - HStack { - SettingsCell( - title: ProfileLocalization.Settings.wifiTitle, - description: ProfileLocalization.Settings.wifiDescription - ) - Toggle(isOn: $viewModel.wifiOnly, label: {}) - .toggleStyle(SwitchToggleStyle(tint: Theme.Colors.toggleSwitchColor)) - .frame(width: 50) - .accessibilityIdentifier("download_agreement_switch") - }.foregroundColor(Theme.Colors.textPrimary) - Divider() - - // MARK: Streaming Quality - HStack { - Button(action: { - viewModel.router.showVideoQualityView(viewModel: viewModel) - }, label: { - SettingsCell(title: ProfileLocalization.Settings.videoQualityTitle, - description: viewModel.selectedQuality.settingsDescription()) - }) - .accessibilityIdentifier("video_stream_quality_button") - // Spacer() - Image(systemName: "chevron.right") - .padding(.trailing, 12) - .frame(width: 10) - .accessibilityIdentifier("video_stream_quality_image") - } - Divider() - - // MARK: Download Quality - HStack { - Button { - viewModel.router.showVideoDownloadQualityView( - downloadQuality: viewModel.userSettings.downloadQuality, - didSelect: viewModel.update(downloadQuality:), - analytics: viewModel.analytics - ) - } label: { + GeometryReader { proxy in + ZStack(alignment: .top) { + + // MARK: - Page Body + ScrollView { + VStack(alignment: .leading, spacing: 24) { + if viewModel.isShowProgress { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 200) + .padding(.horizontal) + .accessibilityIdentifier("progressbar") + } else { + // MARK: Wi-fi + HStack { SettingsCell( - title: CoreLocalization.Settings.videoDownloadQualityTitle, - description: viewModel.userSettings.downloadQuality.settingsDescription + title: ProfileLocalization.Settings.wifiTitle, + description: ProfileLocalization.Settings.wifiDescription ) + Toggle(isOn: $viewModel.wifiOnly, label: {}) + .toggleStyle(SwitchToggleStyle(tint: Theme.Colors.toggleSwitchColor)) + .frame(width: 50) + .accessibilityIdentifier("download_agreement_switch") + }.foregroundColor(Theme.Colors.textPrimary) + Divider() + + // MARK: Streaming Quality + HStack { + Button(action: { + viewModel.router.showVideoQualityView(viewModel: viewModel) + }, label: { + SettingsCell(title: ProfileLocalization.Settings.videoQualityTitle, + description: viewModel.selectedQuality.settingsDescription()) + }) + .accessibilityIdentifier("video_stream_quality_button") + // Spacer() + Image(systemName: "chevron.right") + .padding(.trailing, 12) + .frame(width: 10) + .accessibilityIdentifier("video_stream_quality_image") + } + Divider() + + // MARK: Download Quality + HStack { + Button { + viewModel.router.showVideoDownloadQualityView( + downloadQuality: viewModel.userSettings.downloadQuality, + didSelect: viewModel.update(downloadQuality:), + analytics: viewModel.analytics + ) + } label: { + SettingsCell( + title: CoreLocalization.Settings.videoDownloadQualityTitle, + description: viewModel.userSettings.downloadQuality.settingsDescription + ) + } + .accessibilityIdentifier("video_download_quality_button") + // Spacer() + Image(systemName: "chevron.right") + .padding(.trailing, 12) + .frame(width: 10) + .accessibilityIdentifier("video_download_quality_image") } - .accessibilityIdentifier("video_download_quality_button") - // Spacer() - Image(systemName: "chevron.right") - .padding(.trailing, 12) - .frame(width: 10) - .accessibilityIdentifier("video_download_quality_image") + Divider() } - Divider() } + .frame( + minWidth: 0, + maxWidth: .infinity, + alignment: .topLeading + ) + .padding(.horizontal, 24) + .frameLimit(width: proxy.size.width) } - .frame( - minWidth: 0, - maxWidth: .infinity, - alignment: .topLeading - ) - .padding(.horizontal, 24) - }.frameLimit(sizePortrait: 420) .padding(.top, 8) - - // MARK: - Error Alert - if viewModel.showError { - VStack { - Spacer() - SnackBarView(message: viewModel.errorMessage) - } - .transition(.move(edge: .bottom)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.errorMessage = nil + + // MARK: - Error Alert + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) + } + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } } } } + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(ProfileLocalization.Settings.videoSettingsTitle) + .background( + Theme.Colors.background + .ignoresSafeArea() + ) } - .navigationBarHidden(false) - .navigationBarBackButtonHidden(false) - .navigationTitle(ProfileLocalization.Settings.videoSettingsTitle) - .background( - Theme.Colors.background - .ignoresSafeArea() - ) } } diff --git a/Profile/Profile/Presentation/Settings/VideoQualityView.swift b/Profile/Profile/Presentation/Settings/VideoQualityView.swift index 8d1b03fda..b3decab29 100644 --- a/Profile/Profile/Presentation/Settings/VideoQualityView.swift +++ b/Profile/Profile/Presentation/Settings/VideoQualityView.swift @@ -20,71 +20,74 @@ public struct VideoQualityView: View { } public var body: some View { - ZStack(alignment: .top) { - // MARK: - Page Body - ScrollView { - VStack(alignment: .leading, spacing: 24) { - if viewModel.isShowProgress { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 200) - .padding(.horizontal) - .accessibilityIdentifier("progressbar") - } else { - - ForEach(viewModel.quality, id: \.offset) { _, quality in - Button(action: { - viewModel.analytics.videoQualityChanged( - .videoStreamQualityChanged, - bivalue: .videoStreamQualityChanged, - value: quality.value ?? "", - oldValue: viewModel.selectedQuality.value ?? "" - ) - viewModel.selectedQuality = quality - }, label: { - HStack { - SettingsCell( - title: quality.title(), - description: quality.description() + GeometryReader { proxy in + ZStack(alignment: .top) { + // MARK: - Page Body + ScrollView { + VStack(alignment: .leading, spacing: 24) { + if viewModel.isShowProgress { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 200) + .padding(.horizontal) + .accessibilityIdentifier("progressbar") + } else { + + ForEach(viewModel.quality, id: \.offset) { _, quality in + Button(action: { + viewModel.analytics.videoQualityChanged( + .videoStreamQualityChanged, + bivalue: .videoStreamQualityChanged, + value: quality.value ?? "", + oldValue: viewModel.selectedQuality.value ?? "" ) - Spacer() - CoreAssets.checkmark.swiftUIImage - .renderingMode(.template) - .foregroundColor(Theme.Colors.accentXColor) - .opacity(quality == viewModel.selectedQuality ? 1 : 0) - }.foregroundColor(Theme.Colors.textPrimary) - }) - .accessibilityIdentifier("select_quality_button") - Divider() + viewModel.selectedQuality = quality + }, label: { + HStack { + SettingsCell( + title: quality.title(), + description: quality.description() + ) + Spacer() + CoreAssets.checkmark.swiftUIImage + .renderingMode(.template) + .foregroundColor(Theme.Colors.accentXColor) + .opacity(quality == viewModel.selectedQuality ? 1 : 0) + }.foregroundColor(Theme.Colors.textPrimary) + }) + .accessibilityIdentifier("select_quality_button") + Divider() + } } - } - }.frame(minWidth: 0, - maxWidth: .infinity, - alignment: .topLeading) - .padding(.horizontal, 24) - }.frameLimit(sizePortrait: 420) - .padding(.top, 8) - - // MARK: - Error Alert - if viewModel.showError { - VStack { - Spacer() - SnackBarView(message: viewModel.errorMessage) + }.frame(minWidth: 0, + maxWidth: .infinity, + alignment: .topLeading) + .padding(.horizontal, 24) + .frameLimit(width: proxy.size.width) } - .transition(.move(edge: .bottom)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.errorMessage = nil + .padding(.top, 8) + + // MARK: - Error Alert + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) + } + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } } } } + .navigationBarHidden(false) + .navigationBarBackButtonHidden(false) + .navigationTitle(ProfileLocalization.Settings.videoQualityTitle) + .background( + Theme.Colors.background + .ignoresSafeArea() + ) } - .navigationBarHidden(false) - .navigationBarBackButtonHidden(false) - .navigationTitle(ProfileLocalization.Settings.videoQualityTitle) - .background( - Theme.Colors.background - .ignoresSafeArea() - ) } } From ea50fb167ac27a3593c22f78bc0b6e9097d4b4cd Mon Sep 17 00:00:00 2001 From: Vadim Kuznetsov Date: Fri, 22 Mar 2024 12:10:12 +0300 Subject: [PATCH 086/136] [iOS] There are no subtitles for videos opened from the "Dates" Course section (#340) * fix: subtitles for dates tab [iOS] There are no subtitles for videos opened from the "Dates" Course section #316 * fix: merge conflict --- Core/Core.xcodeproj/project.pbxproj | 8 +++++++ Core/Core/Extensions/Dictionary+JSON.swift | 18 +++++++++++++++ Core/Core/Extensions/String+JSON.swift | 23 +++++++++++++++++++ .../CourseCoreModel.xcdatamodel/contents | 3 ++- OpenEdX/Data/CoursePersistence.swift | 6 ++++- 5 files changed, 56 insertions(+), 2 deletions(-) create mode 100644 Core/Core/Extensions/Dictionary+JSON.swift create mode 100644 Core/Core/Extensions/String+JSON.swift diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 78e956b3b..cf55fd89a 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -83,6 +83,8 @@ 02F6EF4A28D9F0A700835477 /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F6EF4928D9F0A700835477 /* DateExtension.swift */; }; 02F98A7F28F81EE900DE94C0 /* Container+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F98A7E28F81EE900DE94C0 /* Container+App.swift */; }; 0604C9AA2B22FACF00AD5DBF /* UIComponentsConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0604C9A92B22FACF00AD5DBF /* UIComponentsConfig.swift */; }; + 06078B702BA49C3100576798 /* Dictionary+JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06078B6E2BA49C3100576798 /* Dictionary+JSON.swift */; }; + 06078B712BA49C3100576798 /* String+JSON.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06078B6F2BA49C3100576798 /* String+JSON.swift */; }; 064987932B4D69FF0071642A /* DragAndDropCssInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0649878A2B4D69FE0071642A /* DragAndDropCssInjection.swift */; }; 064987942B4D69FF0071642A /* WebviewInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0649878B2B4D69FE0071642A /* WebviewInjection.swift */; }; 064987952B4D69FF0071642A /* SurveyCssInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0649878C2B4D69FE0071642A /* SurveyCssInjection.swift */; }; @@ -259,6 +261,8 @@ 02F6EF4928D9F0A700835477 /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.swift; sourceTree = ""; }; 02F98A7E28F81EE900DE94C0 /* Container+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Container+App.swift"; sourceTree = ""; }; 0604C9A92B22FACF00AD5DBF /* UIComponentsConfig.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIComponentsConfig.swift; sourceTree = ""; }; + 06078B6E2BA49C3100576798 /* Dictionary+JSON.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Dictionary+JSON.swift"; sourceTree = ""; }; + 06078B6F2BA49C3100576798 /* String+JSON.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "String+JSON.swift"; sourceTree = ""; }; 0649878A2B4D69FE0071642A /* DragAndDropCssInjection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DragAndDropCssInjection.swift; sourceTree = ""; }; 0649878B2B4D69FE0071642A /* WebviewInjection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = WebviewInjection.swift; sourceTree = ""; }; 0649878C2B4D69FE0071642A /* SurveyCssInjection.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SurveyCssInjection.swift; sourceTree = ""; }; @@ -450,6 +454,8 @@ 0283347E28D4DCC100C828FC /* Extensions */ = { isa = PBXGroup; children = ( + 06078B6E2BA49C3100576798 /* Dictionary+JSON.swift */, + 06078B6F2BA49C3100576798 /* String+JSON.swift */, 02F164362902A9EB0090DDEF /* StringExtension.swift */, 024BE3DE29B2615500BCDEE2 /* CGColorExtension.swift */, 0283347F28D4DCD200C828FC /* ViewExtension.swift */, @@ -1079,10 +1085,12 @@ 0283348028D4DCD200C828FC /* ViewExtension.swift in Sources */, BA8FA66A2AD59B5500EA029A /* GoogleAuthProvider.swift in Sources */, 02A4833529B8A73400D33F33 /* CorePersistenceProtocol.swift in Sources */, + 06078B712BA49C3100576798 /* String+JSON.swift in Sources */, 0233D5732AF13EEE00BAC8BD /* AppReviewButton.swift in Sources */, 02512FF0299533DF0024D438 /* CoreDataHandlerProtocol.swift in Sources */, 0260E58028FD792800BBBE18 /* WebUnitViewModel.swift in Sources */, 02A4833A29B8A9AB00D33F33 /* DownloadManager.swift in Sources */, + 06078B702BA49C3100576798 /* Dictionary+JSON.swift in Sources */, 027BD3AE2909475000392132 /* KeyboardScrollerOptions.swift in Sources */, BAFB99922B14E23D007D09F9 /* AppleSignInConfig.swift in Sources */, 141F1D302B7328D4009E81EB /* WebviewCookiesUpdateProtocol.swift in Sources */, diff --git a/Core/Core/Extensions/Dictionary+JSON.swift b/Core/Core/Extensions/Dictionary+JSON.swift new file mode 100644 index 000000000..398fc3676 --- /dev/null +++ b/Core/Core/Extensions/Dictionary+JSON.swift @@ -0,0 +1,18 @@ +// +// Dictionary+JSON.swift +// Core +// +// Created by Vadim Kuznetsov on 13.03.24. +// + +import Foundation + +public extension Dictionary where Key == String, Value == String { + public func toJson() -> String? { + guard let jsonData = try? JSONSerialization.data(withJSONObject: self, options: []) else { + return nil + } + + return String(data: jsonData, encoding: .utf8) + } +} diff --git a/Core/Core/Extensions/String+JSON.swift b/Core/Core/Extensions/String+JSON.swift new file mode 100644 index 000000000..ab171369e --- /dev/null +++ b/Core/Core/Extensions/String+JSON.swift @@ -0,0 +1,23 @@ +// +// String+JSON.swift +// Core +// +// Created by Vadim Kuznetsov on 13.03.24. +// + +import Foundation + +public extension String { + public func jsonStringToDictionary() -> [String: Any]? { + guard let jsonData = self.data(using: .utf8) else { + return nil + } + + guard let jsonObject = try? JSONSerialization.jsonObject(with: jsonData, options: []), + let dictionary = jsonObject as? [String: Any] else { + return nil + } + + return dictionary + } +} diff --git a/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents b/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents index 8703670b3..ae4e31bf2 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 @@ - + @@ -11,6 +11,7 @@ + diff --git a/OpenEdX/Data/CoursePersistence.swift b/OpenEdX/Data/CoursePersistence.swift index ca9961d08..931d1e419 100644 --- a/OpenEdX/Data/CoursePersistence.swift +++ b/OpenEdX/Data/CoursePersistence.swift @@ -78,7 +78,7 @@ public class CoursePersistence: CoursePersistenceProtocol { let blocks = try? context.fetch(requestBlocks).map { let userViewData = DataLayer.CourseDetailUserViewData( - transcripts: nil, + transcripts: $0.transcripts?.jsonStringToDictionary() as? [String: String], encodedVideo: DataLayer.CourseDetailEncodedVideoData( youTube: DataLayer.EncodedVideoData( url: $0.youTube?.url, @@ -213,6 +213,10 @@ public class CoursePersistence: CoursePersistenceProtocol { courseDetail.hls = hls } + if let transcripts = block.userViewData?.transcripts { + courseDetail.transcripts = transcripts.toJson() + } + do { try context.save() } catch { From 6f1ec1b6324bb2b3dc12a61ca7ce405237ee5056 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta <127732735+volodymyr-chekyrta@users.noreply.github.com> Date: Fri, 22 Mar 2024 11:11:15 +0200 Subject: [PATCH 087/136] fix: Dashboard parsing error (#360) * fix: remove unused fields to fix parsing error * fix: remove unused fields to fix parsing error --- Core/Core/Data/Model/Data_Dashboard.swift | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/Core/Core/Data/Model/Data_Dashboard.swift b/Core/Core/Data/Model/Data_Dashboard.swift index 603af54b2..d39d8aa2d 100644 --- a/Core/Core/Data/Model/Data_Dashboard.swift +++ b/Core/Core/Data/Model/Data_Dashboard.swift @@ -102,7 +102,6 @@ public extension DataLayer { public let number: String public let org: String public let start: String? - public let startDisplay: String public let startType: StartType public let end: String? public let dynamicUpgradeDeadline: String? @@ -112,9 +111,6 @@ public extension DataLayer { public let courseImage: String public let courseAbout: String public let courseSharingUtmParameters: CourseSharingUtmParameters - public let courseUpdates: String - public let courseHandouts: String - public let discussionURL: String public let videoOutline: String? public let isSelfPaced: Bool @@ -124,7 +120,6 @@ public extension DataLayer { case number case org case start - case startDisplay = "start_display" case startType = "start_type" case end case dynamicUpgradeDeadline = "dynamic_upgrade_deadline" @@ -134,9 +129,6 @@ public extension DataLayer { case courseImage = "course_image" case courseAbout = "course_about" case courseSharingUtmParameters = "course_sharing_utm_parameters" - case courseUpdates = "course_updates" - case courseHandouts = "course_handouts" - case discussionURL = "discussion_url" case videoOutline = "video_outline" case isSelfPaced = "is_self_paced" } @@ -147,7 +139,6 @@ public extension DataLayer { number: String, org: String, start: String?, - startDisplay: String, startType: StartType, end: String?, dynamicUpgradeDeadline: String?, @@ -157,9 +148,6 @@ public extension DataLayer { courseImage: String, courseAbout: String, courseSharingUtmParameters: CourseSharingUtmParameters, - courseUpdates: String, - courseHandouts: String, - discussionURL: String, videoOutline: String?, isSelfPaced: Bool ) { @@ -168,7 +156,6 @@ public extension DataLayer { self.number = number self.org = org self.start = start - self.startDisplay = startDisplay self.startType = startType self.end = end self.dynamicUpgradeDeadline = dynamicUpgradeDeadline @@ -178,9 +165,6 @@ public extension DataLayer { self.courseImage = courseImage self.courseAbout = courseAbout self.courseSharingUtmParameters = courseSharingUtmParameters - self.courseUpdates = courseUpdates - self.courseHandouts = courseHandouts - self.discussionURL = discussionURL self.videoOutline = videoOutline self.isSelfPaced = isSelfPaced } From 700d1adb79c4a0a04f95f1e4fcb91adc48a715f7 Mon Sep 17 00:00:00 2001 From: Eugene Yatsenko Date: Fri, 22 Mar 2024 13:28:48 +0100 Subject: [PATCH 088/136] chore: add fetch is blacked out posts --- .../Discussion/Presentation/DiscussionRouter.swift | 6 +++--- .../DiscussionTopics/DiscussionTopicsView.swift | 5 ++++- .../Discussion/Presentation/Posts/PostsView.swift | 4 ++-- .../Presentation/Posts/PostsViewModel.swift | 11 ++++++++--- OpenEdX/Router.swift | 3 ++- 5 files changed, 19 insertions(+), 10 deletions(-) diff --git a/Discussion/Discussion/Presentation/DiscussionRouter.swift b/Discussion/Discussion/Presentation/DiscussionRouter.swift index 951e22351..57cbf37ab 100644 --- a/Discussion/Discussion/Presentation/DiscussionRouter.swift +++ b/Discussion/Discussion/Presentation/DiscussionRouter.swift @@ -30,8 +30,8 @@ public protocol DiscussionRouter: BaseRouter { animated: Bool ) - func showDiscussionsSearch(courseID: String) - + func showDiscussionsSearch(courseID: String, isBlackedOut: Bool) + func showComments( commentID: String, parentComment: Post, @@ -67,7 +67,7 @@ public class DiscussionRouterMock: BaseRouterMock, DiscussionRouter { animated: Bool ) {} - public func showDiscussionsSearch(courseID: String) {} + public func showDiscussionsSearch(courseID: String, isBlackedOut: Bool) {} public func showComments( commentID: String, diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift index 603aa06ac..b5eb62733 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift @@ -52,7 +52,10 @@ public struct DiscussionTopicsView: View { .fill(Theme.Colors.textInputUnfocusedStroke) ) .onTapGesture { - viewModel.router.showDiscussionsSearch(courseID: courseID) + viewModel.router.showDiscussionsSearch( + courseID: courseID, + isBlackedOut: viewModel.isBlackedOut + ) } .padding(.horizontal, 24) .padding(.bottom, 20) diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index 16820fbd8..45e677bfe 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -30,7 +30,7 @@ public struct PostsView: View { viewModel: PostsViewModel, router: DiscussionRouter, showTopMenu: Bool = true, - isBlackedOut: Bool = false + isBlackedOut: Bool? = nil ) { self.courseID = courseID self.title = title @@ -127,7 +127,7 @@ public struct PostsView: View { .font(Theme.Fonts.titleLarge) .foregroundColor(Theme.Colors.textPrimary) Spacer() - if !viewModel.isBlackedOut { + if !(viewModel.isBlackedOut ?? false) { Button(action: { router.createNewThread( courseID: courseID, diff --git a/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift b/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift index 3f72e7d60..6b770b366 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift @@ -61,8 +61,8 @@ public class PostsViewModel: ObservableObject { @Published var filterButtons: [ActionSheet.Button] = [] public var courseID: String? - var isBlackedOut: Bool = false - + @Published var isBlackedOut: Bool? + var errorMessage: String? { didSet { withAnimation { @@ -163,7 +163,7 @@ public class PostsViewModel: ObservableObject { self.router.showThread( thread: actualThread, postStateSubject: self.postStateSubject, - isBlackedOut: self.isBlackedOut, + isBlackedOut: self.isBlackedOut ?? false, animated: true ) })) @@ -189,6 +189,11 @@ public class PostsViewModel: ObservableObject { fetchInProgress = true isShowProgress = withProgress do { + if let courseID, isBlackedOut == nil { + let discussionInfo = try await interactor.getCourseDiscussionInfo(courseID: courseID) + isBlackedOut = discussionInfo.isBlackedOut() + } + if pageNumber == 1 { threads.threads = try await getThreadsList(type: type, page: pageNumber) if threads.threads.indices.contains(0) { diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 55e806c40..d49c2a771 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -298,8 +298,9 @@ public class Router: AuthorizationRouter, } } - public func showDiscussionsSearch(courseID: String) { + public func showDiscussionsSearch(courseID: String, isBlackedOut: Bool) { let viewModel = Container.shared.resolve(DiscussionSearchTopicsViewModel.self, argument: courseID)! + let view = DiscussionSearchTopicsView(viewModel: viewModel) let controller = UIHostingController(rootView: view) From 592560c2001a8b38cc4cf7d598bf4ed9834759ca Mon Sep 17 00:00:00 2001 From: Eugene Yatsenko Date: Mon, 25 Mar 2024 10:31:30 +0100 Subject: [PATCH 089/136] chore: create discussion hide when blacked out --- .../Presentation/Posts/PostsView.swift | 50 +++++++++++-------- Discussion/Discussion/SwiftGen/Strings.swift | 2 + .../Discussion/en.lproj/Localizable.strings | 1 + .../Discussion/uk.lproj/Localizable.strings | 1 + 4 files changed, 32 insertions(+), 22 deletions(-) diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index 45e677bfe..d6f4ff228 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -182,29 +182,35 @@ public struct PostsView: View { .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .padding(.top, 40) - Text(DiscussionLocalization.Posts.NoDiscussion.description) - .font(Theme.Fonts.bodyLarge) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .padding(.top, 12) - StyledButton( - DiscussionLocalization.Posts.NoDiscussion.createbutton, - action: { - router.createNewThread(courseID: courseID, - selectedTopic: currentBlockID, - onPostCreated: { - reloadPage(onSuccess: { - withAnimation { - scroll.scrollTo(1) + if !(viewModel.isBlackedOut ?? false) { + Text(DiscussionLocalization.Posts.NoDiscussion.description) + .font(Theme.Fonts.bodyLarge) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 12) + StyledButton( + DiscussionLocalization.Posts.NoDiscussion.addPost, + action: { + router.createNewThread( + courseID: courseID, + selectedTopic: currentBlockID, + onPostCreated: { + reloadPage( + onSuccess: { + withAnimation { + scroll.scrollTo(1) + } + } + ) } - }) - }) - }, - isTransparent: true) - .frame(width: 215) - .padding(.top, 40) - .colorMultiply(Theme.Colors.accentColor) - + ) + }, + isTransparent: true + ) + .frame(width: 215) + .padding(.top, 40) + .colorMultiply(Theme.Colors.accentColor) + } }.padding(24) .padding(.top, 100) } diff --git a/Discussion/Discussion/SwiftGen/Strings.swift b/Discussion/Discussion/SwiftGen/Strings.swift index 01a7dd461..637b96f9a 100644 --- a/Discussion/Discussion/SwiftGen/Strings.swift +++ b/Discussion/Discussion/SwiftGen/Strings.swift @@ -91,6 +91,8 @@ public enum DiscussionLocalization { public static let unread = DiscussionLocalization.tr("Localizable", "POSTS.FILTER.UNREAD", fallback: "Unread") } public enum NoDiscussion { + /// Add a post + public static let addPost = DiscussionLocalization.tr("Localizable", "POSTS.NO_DISCUSSION.ADD_POST", fallback: "Add a post") /// Create discussion public static let createbutton = DiscussionLocalization.tr("Localizable", "POSTS.NO_DISCUSSION.CREATEBUTTON", fallback: "Create discussion") /// Click the button below to create your first discussion. diff --git a/Discussion/Discussion/en.lproj/Localizable.strings b/Discussion/Discussion/en.lproj/Localizable.strings index c88b148a3..08a3323a2 100644 --- a/Discussion/Discussion/en.lproj/Localizable.strings +++ b/Discussion/Discussion/en.lproj/Localizable.strings @@ -20,6 +20,7 @@ "POSTS.NO_DISCUSSION.TITLE" = "No discussions yet"; "POSTS.NO_DISCUSSION.DESCRIPTION" = "Click the button below to create your first discussion."; "POSTS.NO_DISCUSSION.CREATEBUTTON" = "Create discussion"; +"POSTS.NO_DISCUSSION.ADD_POST" = "Add a post"; "POSTS.FILTER.ALL_POSTS" = "All Posts"; "POSTS.FILTER.UNREAD" = "Unread"; diff --git a/Discussion/Discussion/uk.lproj/Localizable.strings b/Discussion/Discussion/uk.lproj/Localizable.strings index 923dea01d..c681a8bee 100644 --- a/Discussion/Discussion/uk.lproj/Localizable.strings +++ b/Discussion/Discussion/uk.lproj/Localizable.strings @@ -24,6 +24,7 @@ "POSTS.NO_DISCUSSION.TITLE" = "Ще немає дискусій"; "POSTS.NO_DISCUSSION.DESCRIPTION" = "Натисніть кнопку нижче, щоб створити свою першу дискусію."; "POSTS.NO_DISCUSSION.CREATEBUTTON" = "Створити дискусію"; +"POSTS.NO_DISCUSSION.ADD_POST" = "Add a post"; "POSTS.CREATE_NEW_POST" = "Створити новий пост"; "POSTS.ALERT.MAKE_SELECTION" = "Оберіть"; From 17acafb1efe81ad07a18a8d92c4a197c313b3169 Mon Sep 17 00:00:00 2001 From: forgotvas Date: Mon, 25 Mar 2024 16:37:14 +0300 Subject: [PATCH 090/136] feat: pip mode --- Course/Course.xcodeproj/project.pbxproj | 4 + .../Unit/CourseUnitViewModel.swift | 2 +- .../Video/EncodedVideoPlayer.swift | 8 +- .../Video/EncodedVideoPlayerViewModel.swift | 39 +++++- .../Video/PlayerViewController.swift | 30 ++++- .../Video/PlayerViewControllerHolder.swift | 108 +++++++++++++++++ OpenEdX.xcodeproj/project.pbxproj | 4 + OpenEdX/DI/AppAssembly.swift | 12 ++ OpenEdX/DI/ScreenAssembly.swift | 9 +- OpenEdX/Info.plist | 4 + .../DeepLinkRouter/DeepLinkRouter.swift | 4 + OpenEdX/Managers/PipManager.swift | 113 ++++++++++++++++++ 12 files changed, 320 insertions(+), 17 deletions(-) create mode 100644 Course/Course/Presentation/Video/PlayerViewControllerHolder.swift create mode 100644 OpenEdX/Managers/PipManager.swift diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index 9de40f83f..38647a0b8 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -54,6 +54,7 @@ 02F98A8128F8224200DE94C0 /* Discussion.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02F98A8028F8224200DE94C0 /* Discussion.framework */; }; 02FFAD0D29E4347300140E46 /* VideoPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FFAD0C29E4347300140E46 /* VideoPlayerViewModel.swift */; }; 060E8BCA2B5FD68C0080C952 /* UnitStack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 060E8BC92B5FD68C0080C952 /* UnitStack.swift */; }; + 065275352BB1B39C0093BCCA /* PlayerViewControllerHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 065275342BB1B39C0093BCCA /* PlayerViewControllerHolder.swift */; }; 068DDA5F2B1E198700FF8CCB /* CourseUnitDropDownList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 068DDA5B2B1E198700FF8CCB /* CourseUnitDropDownList.swift */; }; 068DDA602B1E198700FF8CCB /* CourseUnitVerticalsDropdownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 068DDA5C2B1E198700FF8CCB /* CourseUnitVerticalsDropdownView.swift */; }; 068DDA612B1E198700FF8CCB /* CourseUnitDropDownCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 068DDA5D2B1E198700FF8CCB /* CourseUnitDropDownCell.swift */; }; @@ -148,6 +149,7 @@ 02F98A8028F8224200DE94C0 /* Discussion.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Discussion.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 02FFAD0C29E4347300140E46 /* VideoPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoPlayerViewModel.swift; sourceTree = ""; }; 060E8BC92B5FD68C0080C952 /* UnitStack.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitStack.swift; sourceTree = ""; }; + 065275342BB1B39C0093BCCA /* PlayerViewControllerHolder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerViewControllerHolder.swift; sourceTree = ""; }; 068DDA5B2B1E198700FF8CCB /* CourseUnitDropDownList.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CourseUnitDropDownList.swift; sourceTree = ""; }; 068DDA5C2B1E198700FF8CCB /* CourseUnitVerticalsDropdownView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CourseUnitVerticalsDropdownView.swift; sourceTree = ""; }; 068DDA5D2B1E198700FF8CCB /* CourseUnitDropDownCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CourseUnitDropDownCell.swift; sourceTree = ""; }; @@ -440,6 +442,7 @@ 070019AA28F6F79E00D5FC78 /* Video */ = { isa = PBXGroup; children = ( + 065275342BB1B39C0093BCCA /* PlayerViewControllerHolder.swift */, 02F066E729DC71750073E13B /* SubtittlesView.swift */, 0766DFCB299AA7A600EBEF6A /* YouTubeVideoPlayer.swift */, 022F8E152A1DFBC6008EFAB9 /* YouTubeVideoPlayerViewModel.swift */, @@ -816,6 +819,7 @@ 022C64E029ADEA9B000F532B /* Data_UpdatesResponse.swift in Sources */, 02454CA02A2618E70043052A /* YouTubeView.swift in Sources */, 02454CA22A26190A0043052A /* EncodedVideoView.swift in Sources */, + 065275352BB1B39C0093BCCA /* PlayerViewControllerHolder.swift in Sources */, 068DDA612B1E198700FF8CCB /* CourseUnitDropDownCell.swift in Sources */, BAC0E0DE2B32F0F3006B68A9 /* DownloadsViewModel.swift in Sources */, 02B6B3BC28E1D14F00232911 /* CourseRepository.swift in Sources */, diff --git a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift index 586ef427a..d272d3679 100644 --- a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift +++ b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift @@ -142,7 +142,7 @@ public class CourseUnitViewModel: ObservableObject { private func selectLesson() -> Int { guard verticals[verticalIndex].childs.count > 0 else { return 0 } - let index = verticals[verticalIndex].childs.firstIndex(where: { $0.id == lessonID }) ?? 0 + let index = verticals[verticalIndex].childs.firstIndex(where: { $0.id.contains(lessonID) }) ?? 0 nextTitles() return index } diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift index 6249ccb7b..af27fd4ac 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift @@ -59,7 +59,7 @@ public struct EncodedVideoPlayer: View { VStack { PlayerViewController( videoURL: viewModel.url, - controller: viewModel.controller, + playerHolder: viewModel.controllerHolder, bitrate: viewModel.getVideoResolution(), progress: { progress in if progress >= 0.8 { @@ -149,9 +149,11 @@ struct EncodedVideoPlayer_Previews: PreviewProvider { languages: [], playerStateSubject: CurrentValueSubject(nil), interactor: CourseInteractor(repository: CourseRepositoryMock()), - router: CourseRouterMock(), + router: CourseRouterMock(), appStorage: CoreStorageMock(), - connectivity: Connectivity() + connectivity: Connectivity(), + pipManager: PipManagerProtocolMock(), + isVideoTab: false ), isOnScreen: true ) diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift index 6163c8f93..0f6ba0cca 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift @@ -8,12 +8,16 @@ import _AVKit_SwiftUI import Core import Combine +import Swinject public class EncodedVideoPlayerViewModel: VideoPlayerViewModel { let url: URL? - let controller = AVPlayerViewController() + let controllerHolder: PlayerViewControllerHolder + var controller: AVPlayerViewController { + controllerHolder.playerController + } private var subscription = Set() public init( @@ -25,24 +29,49 @@ public class EncodedVideoPlayerViewModel: VideoPlayerViewModel { interactor: CourseInteractorProtocol, router: CourseRouter, appStorage: CoreStorage, - connectivity: ConnectivityProtocol + connectivity: ConnectivityProtocol, + pipManager: PipManagerProtocol, + isVideoTab: Bool ) { self.url = url + if let holder = pipManager.holder( + for: url, + blockID: blockID, + courseID: courseID, + isVideoTab: isVideoTab + ) { + print("ALARM restore holder") + controllerHolder = holder + } else { + print("ALARM create holder") + let holder = PlayerViewControllerHolder( + url: url, + blockID: blockID, + courseID: courseID, + isVideoTab: isVideoTab + ) + controllerHolder = holder + } + super.init(blockID: blockID, courseID: courseID, languages: languages, interactor: interactor, - router: router, + router: router, appStorage: appStorage, connectivity: connectivity) playerStateSubject.sink(receiveValue: { [weak self] state in switch state { case .pause: - self?.controller.player?.pause() + if self?.controllerHolder.isPipModeActive != true { + self?.controller.player?.pause() + } case .kill: - self?.controller.player?.replaceCurrentItem(with: nil) + if self?.controllerHolder.isPipModeActive != true { + self?.controller.player?.replaceCurrentItem(with: nil) + } case .none: break } diff --git a/Course/Course/Presentation/Video/PlayerViewController.swift b/Course/Course/Presentation/Video/PlayerViewController.swift index 40938029f..92e30b662 100644 --- a/Course/Course/Presentation/Video/PlayerViewController.swift +++ b/Course/Course/Presentation/Video/PlayerViewController.swift @@ -5,6 +5,8 @@ // Created by Vladimir Chekyrta on 13.02.2023. // +import Combine +import Core import SwiftUI import _AVKit_SwiftUI @@ -12,27 +14,34 @@ struct PlayerViewController: UIViewControllerRepresentable { var videoURL: URL? var videoResolution: CGSize - var controller: AVPlayerViewController + var playerHolder: PlayerViewControllerHolder var progress: ((Float) -> Void) var seconds: ((Double) -> Void) init( videoURL: URL?, - controller: AVPlayerViewController, + playerHolder: PlayerViewControllerHolder, bitrate: CGSize, progress: @escaping ((Float) -> Void), seconds: @escaping ((Double) -> Void) ) { self.videoURL = videoURL - self.controller = controller + self.playerHolder = playerHolder self.videoResolution = bitrate self.progress = progress self.seconds = seconds } func makeUIViewController(context: Context) -> AVPlayerViewController { + if playerHolder.isPipModeActive { + return playerHolder.playerController + } + + print("ALARM create new player") + let controller = playerHolder.playerController controller.modalPresentationStyle = .fullScreen controller.allowsPictureInPicturePlayback = true + controller.canStartPictureInPictureAutomaticallyFromInline = true let player = AVPlayer() controller.player = player context.coordinator.setPlayer(player) { progress, seconds in @@ -51,7 +60,8 @@ struct PlayerViewController: UIViewControllerRepresentable { func updateUIViewController(_ playerController: AVPlayerViewController, context: Context) { let asset = playerController.player?.currentItem?.asset as? AVURLAsset - if asset?.url.absoluteString != videoURL?.absoluteString { + if asset?.url.absoluteString != videoURL?.absoluteString && !playerHolder.isPipModeActive { + print("ALARM replace player") let player = context.coordinator.player(from: playerController) player?.replaceCurrentItem(with: AVPlayerItem(url: videoURL!)) player?.currentItem?.preferredMaximumResolution = videoResolution @@ -74,6 +84,7 @@ struct PlayerViewController: UIViewControllerRepresentable { class Coordinator { var currentPlayer: AVPlayer? var observer: Any? + var cancellations: [AnyCancellable] = [] func player(from playerController: AVPlayerViewController) -> AVPlayer? { var player = playerController.player @@ -86,6 +97,8 @@ struct PlayerViewController: UIViewControllerRepresentable { } func setPlayer(_ player: AVPlayer?, currentProgress: @escaping ((Float, Double) -> Void)) { + guard let player = player else { return } + cancellations.removeAll() if let observer = observer { currentPlayer?.removeTimeObserver(observer) currentPlayer?.pause() @@ -96,7 +109,7 @@ struct PlayerViewController: UIViewControllerRepresentable { preferredTimescale: CMTimeScale(NSEC_PER_SEC) ) - observer = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) {[weak player] time in + observer = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) {[weak player] time in var progress: Float = .zero let currentSeconds = CMTimeGetSeconds(time) guard let duration = player?.currentItem?.duration else { return } @@ -105,6 +118,13 @@ struct PlayerViewController: UIViewControllerRepresentable { currentProgress(progress, currentSeconds) } + player.publisher(for: \.rate) + .sink { rate in + guard rate > 0 else { return } + + } + .store(in: &cancellations) + currentPlayer = player } diff --git a/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift b/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift new file mode 100644 index 000000000..2393d7e82 --- /dev/null +++ b/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift @@ -0,0 +1,108 @@ +// +// PlayerViewControllerHolder.swift +// Core +// +// Created by Vadim Kuznetsov on 20.03.24. +// + +import AVKit +import Combine +import Swinject + +public protocol PipManagerProtocol { + func holder(for url: URL?, blockID: String, courseID: String, isVideoTab: Bool) -> PlayerViewControllerHolder? + func set(holder: PlayerViewControllerHolder) + func remove(holder: PlayerViewControllerHolder) + func restore(holder: PlayerViewControllerHolder) async throws + func appearancePublisher(for holder: PlayerViewControllerHolder) -> AnyPublisher? +} + +#if DEBUG +public class PipManagerProtocolMock: PipManagerProtocol { + public init() {} + public func holder( + for url: URL?, + blockID: String, + courseID: String, + isVideoTab: Bool + ) -> PlayerViewControllerHolder? { + return nil + } + public func set(holder: PlayerViewControllerHolder) {} + public func remove(holder: PlayerViewControllerHolder) {} + public func restore(holder: PlayerViewControllerHolder) async throws {} + public func appearancePublisher(for holder: PlayerViewControllerHolder) -> AnyPublisher? { + return nil + } +} +#endif + +public class PlayerViewControllerHolder: NSObject, AVPlayerViewControllerDelegate { + public let url: URL? + public let blockID: String + public let courseID: String + public let isVideoTab: Bool + public var isPipModeActive: Bool = false + + public lazy var playerController: AVPlayerViewController = { + let playerController = AVPlayerViewController() + playerController.delegate = self + return playerController + }() + + public init( + url: URL?, + blockID: String, + courseID: String, + isVideoTab: Bool + ) { + self.url = url + self.blockID = blockID + self.courseID = courseID + self.isVideoTab = isVideoTab + } + + public func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) { + isPipModeActive = true + Container.shared.resolve(PipManagerProtocol.self)?.set(holder: self) + } + +// func playerViewControllerDidStartPictureInPicture(_ playerViewController: AVPlayerViewController) { +// +// } + + public func playerViewController(_ playerViewController: AVPlayerViewController, failedToStartPictureInPictureWithError error: any Error) { + isPipModeActive = false + Container.shared.resolve(PipManagerProtocol.self)?.remove(holder: self) + print("ALARM failed to start \(error)") + } + + public func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) { + isPipModeActive = false + Container.shared.resolve(PipManagerProtocol.self)?.remove(holder: self) + print("ALARM did stop picture in picture") + } + +// func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) { +// +// } + public func playerViewControllerRestoreUserInterfaceForPictureInPictureStop(_ playerViewController: AVPlayerViewController) async -> Bool { + print("ALARM restore controller") + do { + try await Container.shared.resolve(PipManagerProtocol.self)?.restore(holder: self) + print("ALARM restore completed") + return true + } catch { + print("ALARM restore failed") + return false + } + } + + static func == (lhs: PlayerViewControllerHolder, rhs: PlayerViewControllerHolder) -> Bool { + lhs.url?.absoluteString == rhs.url?.absoluteString && + lhs.courseID == rhs.courseID && + lhs.blockID == rhs.blockID && + lhs.isVideoTab == rhs.isVideoTab + } +} + diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 043a37fbf..625fc1c8a 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -28,6 +28,7 @@ 02ED50D429A6554E008341CD /* сountries.json in Resources */ = {isa = PBXBuildFile; fileRef = 02ED50D629A6554E008341CD /* сountries.json */; }; 02ED50D829A66007008341CD /* languages.json in Resources */ = {isa = PBXBuildFile; fileRef = 02ED50DA29A66007008341CD /* languages.json */; }; 02F175312A4DA95B0019CD70 /* MainScreenAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F175302A4DA95B0019CD70 /* MainScreenAnalytics.swift */; }; + 065275372BB1B4070093BCCA /* PipManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 065275362BB1B4070093BCCA /* PipManager.swift */; }; 071009C928D1DB3F00344290 /* ScreenAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 071009C828D1DB3F00344290 /* ScreenAssembly.swift */; }; 0727878E28D347C7002E9142 /* MainScreenView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0727878D28D347C7002E9142 /* MainScreenView.swift */; }; 072787B128D34D83002E9142 /* Discovery.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 072787B028D34D83002E9142 /* Discovery.framework */; }; @@ -112,6 +113,7 @@ 02ED50D929A66007008341CD /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = Base; path = Base.lproj/languages.json; sourceTree = ""; }; 02ED50DB29A6600B008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = uk; path = uk.lproj/languages.json; sourceTree = ""; }; 02F175302A4DA95B0019CD70 /* MainScreenAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainScreenAnalytics.swift; sourceTree = ""; }; + 065275362BB1B4070093BCCA /* PipManager.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PipManager.swift; sourceTree = ""; }; 071009C828D1DB3F00344290 /* ScreenAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenAssembly.swift; sourceTree = ""; }; 0727878D28D347C7002E9142 /* MainScreenView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainScreenView.swift; sourceTree = ""; }; 072787B028D34D83002E9142 /* Discovery.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Discovery.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -292,6 +294,7 @@ A50066882B613E800024680B /* Managers */ = { isa = PBXGroup; children = ( + 065275362BB1B4070093BCCA /* PipManager.swift */, A59568932B6162E400ED4F90 /* DeepLinkManager */, A50066872B613E4B0024680B /* PushNotificationsManager */, A50066892B613E990024680B /* AnalyticsManager */, @@ -576,6 +579,7 @@ A500668D2B6143000024680B /* FCMProvider.swift in Sources */, 025AD4AC2A6FB95C00AB8FA7 /* DatabaseManager.swift in Sources */, 024E69202AEFC3FB00FA0B59 /* MainScreenViewModel.swift in Sources */, + 065275372BB1B4070093BCCA /* PipManager.swift in Sources */, 0770DE2028D0858A006D8A5D /* Router.swift in Sources */, 0293A2092A6FCDE50090A336 /* DashboardPersistence.swift in Sources */, 0770DE1728D080A1006D8A5D /* RouteController.swift in Sources */, diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 8077b4d8d..54a667cdb 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -119,6 +119,10 @@ class AppAssembly: Assembly { r.resolve(Router.self)! }.inObjectScope(.container) + container.register(DeepLinkRouter.self) { r in + r.resolve(Router.self)! + }.inObjectScope(.container) + container.register(ConfigProtocol.self) { _ in Config() }.inObjectScope(.container) @@ -193,6 +197,14 @@ class AppAssembly: Assembly { config: r.resolve(ConfigProtocol.self)! ) }.inObjectScope(.container) + + container.register(PipManagerProtocol.self) { r in + PipManager( + router: r.resolve(Router.self)!, + discoveryInteractor: r.resolve(DiscoveryInteractorProtocol.self)!, + courseInteractor: r.resolve(CourseInteractorProtocol.self)! + ) + }.inObjectScope(.container) } } // swiftlint:enable function_body_length diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 74074812d..414db26f2 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -327,16 +327,19 @@ class ScreenAssembly: Assembly { container.register( EncodedVideoPlayerViewModel.self ) { r, url, blockID, courseID, languages, playerStateSubject in - EncodedVideoPlayerViewModel( + let router: Router = r.resolve(Router.self)! + return EncodedVideoPlayerViewModel( url: url, blockID: blockID, courseID: courseID, 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)! + connectivity: r.resolve(ConnectivityProtocol.self)!, + pipManager: r.resolve(PipManagerProtocol.self)!, + isVideoTab: router.isVideoTab ) } diff --git a/OpenEdX/Info.plist b/OpenEdX/Info.plist index dc623f961..06a6f9e11 100644 --- a/OpenEdX/Info.plist +++ b/OpenEdX/Info.plist @@ -29,6 +29,10 @@ UIAppFonts + UIBackgroundModes + + audio + UIViewControllerBasedStatusBarAppearance diff --git a/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift b/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift index 48a37bd20..83109d73f 100644 --- a/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift +++ b/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift @@ -58,6 +58,10 @@ extension Router: DeepLinkRouter { // MARK: - DeepLinkRouter + public var isVideoTab: Bool { + self.hostCourseContainerView?.rootView.viewModel.selection == CourseTab.videos.rawValue + } + public func showDiscoveryDetails( link: DeepLink, pathID: String diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift new file mode 100644 index 000000000..cdcd575d5 --- /dev/null +++ b/OpenEdX/Managers/PipManager.swift @@ -0,0 +1,113 @@ +// +// PipManager.swift +// OpenEdX +// +// Created by Vadim Kuznetsov on 20.03.24. +// + +import Combine +import Course +import Discovery +import Foundation + +public class PipManager: PipManagerProtocol { + var controllerHolder: PlayerViewControllerHolder? + private var appearancePublisher = PassthroughSubject() + private var restorationTask: Task? + private var cancellations: [AnyCancellable] = [] + let discoveryInteractor: DiscoveryInteractorProtocol + let courseInteractor: CourseInteractorProtocol + let router: Router + public init( + router: Router, + discoveryInteractor: DiscoveryInteractorProtocol, + courseInteractor: CourseInteractorProtocol + ) { + self.discoveryInteractor = discoveryInteractor + self.courseInteractor = courseInteractor + self.router = router + } + + public func holder( + for url: URL?, + blockID: String, + courseID: String, + isVideoTab: Bool + ) -> PlayerViewControllerHolder? { + if controllerHolder?.blockID == blockID, + controllerHolder?.courseID == courseID, + controllerHolder?.isVideoTab == isVideoTab { + return controllerHolder + } + + return nil + } + + public func set(holder: PlayerViewControllerHolder) { + controllerHolder = holder + appearancePublisher = PassthroughSubject() + cancellations.removeAll() + restorationTask?.cancel() + restorationTask = nil + } + + public func remove(holder: PlayerViewControllerHolder) { + if controllerHolder == holder { + controllerHolder = nil + restorationTask?.cancel() + restorationTask = nil + } + } + + @MainActor + public func restore(holder: PlayerViewControllerHolder) async throws { + let courseID = holder.courseID + var courseDetails: CourseDetails? + + if let value = try? await discoveryInteractor.getLoadedCourseDetails( + courseID: courseID + ) { + courseDetails = value + } else { + courseDetails = try await discoveryInteractor.getCourseDetails( + courseID: courseID + ) + } + guard let courseDetails = courseDetails else { throw PipManagerError.cantGetCourseDetails } + let link = DeepLink(dictionary: [:]) + link.type = holder.isVideoTab ? .courseVideos : .courseDashboard + await showCourseDetail(link: link, courseDetails: courseDetails) + + var courseStructure = try await courseInteractor.getLoadedCourseBlocks(courseID: courseID) + if holder.isVideoTab { + courseStructure = courseInteractor.getCourseVideoBlocks(fullStructure: courseStructure) + } + router.showCourseComponent(componentID: holder.blockID, courseStructure: courseStructure) + } + + @MainActor + func showCourseDetail(link: DeepLink, courseDetails: CourseDetails) async { + await withCheckedContinuation { continuation in + router.showCourseDetail( + link: link, + courseDetails: courseDetails + ) { + continuation.resume() + } + } + } + + public func appearancePublisher(for holder: Course.PlayerViewControllerHolder) -> AnyPublisher? { + if holder == controllerHolder { + return appearancePublisher + .eraseToAnyPublisher() + } + return nil + } +} + +extension PipManager { + enum PipManagerError: Error { + case cantGetCourseDetails + } +} From 513a88301aa052b7eba14f5fb452a569f9f8be2e Mon Sep 17 00:00:00 2001 From: Vadim Kuznetsov Date: Tue, 26 Mar 2024 12:06:21 +0300 Subject: [PATCH 091/136] [iOS] UI bug on iPad when using filters in Discussion (#333) * fix: iPad register and login buttons * fix: IPad sign up view * fix: iPad Course outline view * feat: added IPAD_STRETCH config parameter * fix: size bug on small device * fix: iPad stretch for content types * fix: player on iPad and small devices * fix: stretch for search bar on discussions page * fix: discussion search bar stretch * fix: bug of player on small devices * fix: removed stretching for sign up/sign in views * feat: removed feature flag * feat: added readable content size * fix: added readable content size to startup screen * fix: sign in, sign up, reset password readable paddings * fix: scroll bar position for edit profile view * fix: social buttons * feat: added readability and accessibility injections * fix: resize of content and added readability for content types * fix: readability width calculation * feat: added Discussons paddings * feat: added iPad paddings for Dates tab * feat: added injection to html webview * feat: added readable for profile * feat: added padding for tab menu * feat: added paddings to dashboard view * fix: merge conflict * feat: moved filter buttons * feat: added paddings to Delete Account View * feat: added paddings for native discovery view * feat: added padding for CourseVerticalsView * feat: added paddings for responses view * feat: added paddings to UserProfileView * fix: do not block scroll from side for UserProfileView * chore: review's changes * chore: merge conflicts * chore: merge conflict * chore: warning * fix: UI bug on iPad when using filters in Discussion #308 * fix: merge conflicts * chore: merge conflict * chore: removed extra horizontal padding * Revert "chore: removed extra horizontal padding" This reverts commit 933f366147f126d123b416b1dfac4557fa74044d. * chore: removed extra horizontal padding * chore: refactor * chore: label alignment * chore: merge conflicts * chore: status bar fix * chore: merge conflict * chore: set title to be visible * chore: do not show title on iPad * chore: removed useless code and fix warnings * chore: review's require changes * chore: fixed warnings * chore: merge fix --- .../Discussion/Domain/Model/ThreadType.swift | 25 ++++- .../Presentation/Posts/PostsView.swift | 93 ++++++++++++++----- .../Presentation/Posts/PostsViewModel.swift | 78 ++++++---------- 3 files changed, 117 insertions(+), 79 deletions(-) diff --git a/Discussion/Discussion/Domain/Model/ThreadType.swift b/Discussion/Discussion/Domain/Model/ThreadType.swift index 84e936aa3..d744b4552 100644 --- a/Discussion/Discussion/Domain/Model/ThreadType.swift +++ b/Discussion/Discussion/Domain/Model/ThreadType.swift @@ -14,17 +14,36 @@ public enum ThreadType { case courseTopics(topicID: String) } -public enum ThreadsFilter { +public enum ThreadsFilter: Identifiable { + public var id: String { + localizedValue + } + case allThreads case unread case unanswered + + var localizedValue: String { + switch self { + case .allThreads: + return DiscussionLocalization.Posts.Filter.allPosts + case .unread: + return DiscussionLocalization.Posts.Filter.unread + case .unanswered: + return DiscussionLocalization.Posts.Filter.unanswered + } + } } -public enum SortType { +public enum SortType: Identifiable { + public var id: String { + localizedValue + } + case recentActivity case mostActivity case mostVotes - + var localizedValue: String { switch self { case .recentActivity: diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index 329d219bf..caeed2c6f 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -13,12 +13,16 @@ import Theme public struct PostsView: View { @ObservedObject private var viewModel: PostsViewModel - @State private var showingAlert = false + @State private var showFilterSheet = false + @State private var showSortSheet = false private let router: DiscussionRouter private let title: String private let currentBlockID: String private let courseID: String private var showTopMenu: Bool + private var isPad: Bool { + UIDevice.current.userInterfaceIdiom == .pad + } public init( courseID: String, @@ -73,25 +77,10 @@ public struct PostsView: View { VStack { HStack { Group { - Button(action: { - viewModel.generateButtons(type: .filter) - showingAlert = true - }, label: { - CoreAssets.filter.swiftUIImage.renderingMode(.template) - .foregroundColor(Theme.Colors.accentXColor) - Text(viewModel.filterTitle.localizedValue) - }) + filterButton Spacer() - Button(action: { - viewModel.generateButtons(type: .sort) - showingAlert = true - }, label: { - CoreAssets.sort.swiftUIImage.renderingMode(.template) - .foregroundColor(Theme.Colors.accentXColor) - Text(viewModel.sortTitle.localizedValue) - }) - } - .foregroundColor(Theme.Colors.accentColor) + sortButton + }.foregroundColor(Theme.Colors.accentColor) } .font(Theme.Fonts.labelMedium) .padding(.horizontal, 24) @@ -210,7 +199,6 @@ public struct PostsView: View { } } .accessibilityAction {} - .animation(nil) .onRightSwipeGesture { router.back() } @@ -241,13 +229,70 @@ public struct PostsView: View { Theme.Colors.background .ignoresSafeArea() ) - // MARK: - Action Sheet - .actionSheet(isPresented: $showingAlert, content: { - ActionSheet(title: Text(DiscussionLocalization.Posts.Alert.makeSelection), buttons: viewModel.filterButtons) - }) } } + private var filterButton: some View { + Button( + action: { + showFilterSheet = true + }, + label: { + CoreAssets.filter.swiftUIImage + .renderingMode(.template) + .foregroundColor(Theme.Colors.accentXColor) + Text(viewModel.filterTitle.localizedValue) + } + ) + .confirmationDialog( + DiscussionLocalization.Posts.Alert.makeSelection, + isPresented: $showFilterSheet, + titleVisibility: isPad ? .automatic : .visible, + actions: { + ForEach(viewModel.filterInfos) { info in + Button( + action: { + viewModel.filter(by: info) + }, + label: { + Text(info.localizedValue) + } + ) + } + } + ) + } + + private var sortButton: some View { + Button( + action: { + showSortSheet = true + }, + label: { + CoreAssets.sort.swiftUIImage.renderingMode(.template) + .foregroundColor(Theme.Colors.accentXColor) + Text(viewModel.sortTitle.localizedValue) + } + ) + .confirmationDialog( + DiscussionLocalization.Posts.Alert.makeSelection, + isPresented: $showSortSheet, + titleVisibility: isPad ? .automatic : .visible, + actions: { + ForEach(viewModel.sortInfos) { info in + Button( + action: { + viewModel.sort(by: info) + }, + label: { + Text(info.localizedValue) + } + ) + } + } + ) + } + @MainActor private func reloadPage(onSuccess: @escaping () -> Void) { Task { diff --git a/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift b/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift index af567009c..1020417d0 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift @@ -10,20 +10,6 @@ import SwiftUI import Combine import Core -public extension ThreadsFilter { - - var localizedValue: String { - switch self { - case .allThreads: - return DiscussionLocalization.Posts.Filter.allPosts - case .unread: - return DiscussionLocalization.Posts.Filter.unread - case .unanswered: - return DiscussionLocalization.Posts.Filter.unanswered - } - } -} - public class PostsViewModel: ObservableObject { public var nextPage = 1 @@ -40,7 +26,7 @@ public class PostsViewModel: ObservableObject { @Published var filteredPosts: [DiscussionPost] = [] @Published var filterTitle: ThreadsFilter = .allThreads { willSet { - if let courseID { + if courseID != nil { resetPosts() Task { _ = await getPosts(pageNumber: 1) @@ -50,7 +36,7 @@ public class PostsViewModel: ObservableObject { } @Published var sortTitle: SortType = .recentActivity { willSet { - if let courseID { + if courseID != nil { resetPosts() Task { _ = await getPosts(pageNumber: 1) @@ -58,7 +44,22 @@ public class PostsViewModel: ObservableObject { } } } - @Published var filterButtons: [ActionSheet.Button] = [] + + var filterInfos: [ThreadsFilter] { + [ + .allThreads, + .unread, + .unanswered + ] + } + + var sortInfos: [SortType] { + [ + .recentActivity, + .mostActivity, + .mostVotes + ] + } public var courseID: String? var errorMessage: String? { @@ -113,41 +114,14 @@ public class PostsViewModel: ObservableObject { totalPages = 1 } - public func generateButtons(type: ButtonType) { - switch type { - case .sort: - self.filterButtons = [ - ActionSheet.Button.default(Text(DiscussionLocalization.Posts.Sort.recentActivity)) { - self.sortTitle = .recentActivity - self.filteredPosts = self.discussionPosts - }, - ActionSheet.Button.default(Text(DiscussionLocalization.Posts.Sort.mostActivity)) { - self.sortTitle = .mostActivity - self.filteredPosts = self.discussionPosts - }, - ActionSheet.Button.default(Text(DiscussionLocalization.Posts.Sort.mostVotes)) { - self.sortTitle = .mostVotes - self.filteredPosts = self.discussionPosts - }, - .cancel() - ] - case .filter: - self.filterButtons = [ - ActionSheet.Button.default(Text(DiscussionLocalization.Posts.Filter.allPosts)) { - self.filterTitle = .allThreads - self.filteredPosts = self.discussionPosts - }, - ActionSheet.Button.default(Text(DiscussionLocalization.Posts.Filter.unread)) { - self.filterTitle = .unread - self.filteredPosts = self.discussionPosts - }, - ActionSheet.Button.default(Text(DiscussionLocalization.Posts.Filter.unanswered)) { - self.filterTitle = .unanswered - self.filteredPosts = self.discussionPosts - }, - .cancel() - ] - } + public func sort(by value: SortType) { + self.sortTitle = value + self.filteredPosts = self.discussionPosts + } + + public func filter(by value: ThreadsFilter) { + self.filterTitle = value + self.filteredPosts = self.discussionPosts } func generatePosts(threads: ThreadLists?) -> [DiscussionPost] { From d02b30331ebcde3cf00d601f408a90a5a182ea6a Mon Sep 17 00:00:00 2001 From: Shafqat Muneer Date: Tue, 26 Mar 2024 17:39:07 +0500 Subject: [PATCH 092/136] feat: Dates Tab integration with Calendar --- .../AuthorizationMock.generated.swift | 46 +- .../sync_to_calendar.imageset/Contents.json | 15 + .../placeholder-icon.svg | 4 + Core/Core/Configuration/BaseRouter.swift | 4 +- Core/Core/Domain/Model/CourseBlockModel.swift | 11 +- Core/Core/SwiftGen/Assets.swift | 1 + Core/Core/SwiftGen/Strings.swift | 10 + Core/Core/View/Base/AlertView.swift | 45 +- Core/Core/en.lproj/Localizable.strings | 6 + Core/Core/uk.lproj/Localizable.strings | 6 + Course/Course.xcodeproj/project.pbxproj | 24 +- Course/Course/Data/CourseRepository.swift | 2 + .../Model/Data_CourseOutlineResponse.swift | 4 + .../CourseCoreModel.xcdatamodel/contents | 3 +- Course/Course/Domain/Model/CourseDates.swift | 21 + Course/Course/Managers/CalendarManager.swift | 480 ++++++++++++++++++ .../Container/CourseContainerView.swift | 2 +- Course/Course/Presentation/CourseRouter.swift | 6 +- .../Presentation/Dates/CourseDatesView.swift | 93 +++- .../Dates/CourseDatesViewModel.swift | 254 ++++++++- .../Outline/ContinueWithView.swift | 2 + .../Outline/CourseOutlineView.swift | 7 +- .../CourseVerticalImageView.swift | 5 + .../Presentation/Unit/CourseUnitView.swift | 4 + .../DropdownList/CourseUnitDropDownCell.swift | 1 + .../DropdownList/CourseUnitDropDownList.swift | 4 + .../CourseUnitVerticalsDropdownView.swift | 4 + Course/Course/SwiftGen/Strings.swift | 47 ++ .../Views/CalendarSyncProgressView.swift | 67 +++ ...ccessView.swift => DatesSuccessView.swift} | 66 ++- Course/Course/en.lproj/Localizable.strings | 19 + Course/Course/uk.lproj/Localizable.strings | 19 + Course/CourseTests/CourseMock.generated.swift | 23 +- .../CourseContainerViewModelTests.swift | 9 + .../Unit/CourseDateViewModelTests.swift | 34 +- .../Unit/CourseUnitViewModelTests.swift | 4 + .../DashboardMock.generated.swift | 23 +- .../DiscoveryMock.generated.swift | 23 +- .../DiscussionMock.generated.swift | 46 +- OpenEdX/DI/ScreenAssembly.swift | 4 +- OpenEdX/Data/CoursePersistence.swift | 1 + OpenEdX/Info.plist | 4 + OpenEdX/Router.swift | 75 ++- .../ProfileTests/ProfileMock.generated.swift | 46 +- 44 files changed, 1418 insertions(+), 156 deletions(-) create mode 100644 Core/Core/Assets.xcassets/CourseDates/sync_to_calendar.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/CourseDates/sync_to_calendar.imageset/placeholder-icon.svg create mode 100644 Course/Course/Managers/CalendarManager.swift create mode 100644 Course/Course/Views/CalendarSyncProgressView.swift rename Course/Course/Views/{DatesShiftedSuccessView.swift => DatesSuccessView.swift} (66%) diff --git a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift index b31d6c690..e1c3b12b5 100644 --- a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift +++ b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift @@ -920,10 +920,10 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { perform?(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`) } - open func presentView(transitionStyle: UIModalTransitionStyle, view: any View) { - addInvocation(.m_presentView__transitionStyle_transitionStyleview_view(Parameter.value(`transitionStyle`), Parameter.value(`view`))) - let perform = methodPerformValue(.m_presentView__transitionStyle_transitionStyleview_view(Parameter.value(`transitionStyle`), Parameter.value(`view`))) as? (UIModalTransitionStyle, any View) -> Void - perform?(`transitionStyle`, `view`) + open func presentView(transitionStyle: UIModalTransitionStyle, view: any View, completion: (() -> Void)?) { + addInvocation(.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter.value(`transitionStyle`), Parameter.value(`view`), Parameter<(() -> Void)?>.value(`completion`))) + let perform = methodPerformValue(.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter.value(`transitionStyle`), Parameter.value(`view`), Parameter<(() -> Void)?>.value(`completion`))) as? (UIModalTransitionStyle, any View, (() -> Void)?) -> Void + perform?(`transitionStyle`, `view`, `completion`) } open func presentView(transitionStyle: UIModalTransitionStyle, animated: Bool, content: () -> any View) { @@ -949,7 +949,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) - case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) + case m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) case m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(Parameter, Parameter, Parameter<() -> any View>) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -1034,10 +1034,11 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectiontapped, rhs: rhsNextsectiontapped, with: matcher), lhsNextsectiontapped, rhsNextsectiontapped, "nextSectionTapped")) return Matcher.ComparisonResult(results) - case (.m_presentView__transitionStyle_transitionStyleview_view(let lhsTransitionstyle, let lhsView), .m_presentView__transitionStyle_transitionStyleview_view(let rhsTransitionstyle, let rhsView)): + case (.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(let lhsTransitionstyle, let lhsView, let lhsCompletion), .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(let rhsTransitionstyle, let rhsView, let rhsCompletion)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTransitionstyle, rhs: rhsTransitionstyle, with: matcher), lhsTransitionstyle, rhsTransitionstyle, "transitionStyle")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsView, rhs: rhsView, with: matcher), lhsView, rhsView, "view")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCompletion, rhs: rhsCompletion, with: matcher), lhsCompletion, rhsCompletion, "completion")) return Matcher.ComparisonResult(results) case (.m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(let lhsTransitionstyle, let lhsAnimated, let lhsContent), .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(let rhsTransitionstyle, let rhsAnimated, let rhsContent)): @@ -1067,7 +1068,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue - case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue + case let .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case let .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue } } @@ -1088,7 +1089,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" - case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" + case .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" case .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content: return ".presentView(transitionStyle:animated:content:)" } } @@ -1123,7 +1124,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { public static func showWebBrowser(title: Parameter, url: Parameter) -> Verify { return Verify(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} - public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} + public static func presentView(transitionStyle: Parameter, view: Parameter, completion: Parameter<(() -> Void)?>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`))} public static func presentView(transitionStyle: Parameter, animated: Parameter, content: Parameter<() -> any View>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(`transitionStyle`, `animated`, `content`))} } @@ -1176,8 +1177,8 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>, perform: @escaping (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`), performs: perform) } - public static func presentView(transitionStyle: Parameter, view: Parameter, perform: @escaping (UIModalTransitionStyle, any View) -> Void) -> Perform { - return Perform(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`), performs: perform) + public static func presentView(transitionStyle: Parameter, view: Parameter, completion: Parameter<(() -> Void)?>, perform: @escaping (UIModalTransitionStyle, any View, (() -> Void)?) -> Void) -> Perform { + return Perform(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`), performs: perform) } public static func presentView(transitionStyle: Parameter, animated: Parameter, content: Parameter<() -> any View>, perform: @escaping (UIModalTransitionStyle, Bool, () -> any View) -> Void) -> Perform { return Perform(method: .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(`transitionStyle`, `animated`, `content`), performs: perform) @@ -1385,10 +1386,10 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`) } - open func presentView(transitionStyle: UIModalTransitionStyle, view: any View) { - addInvocation(.m_presentView__transitionStyle_transitionStyleview_view(Parameter.value(`transitionStyle`), Parameter.value(`view`))) - let perform = methodPerformValue(.m_presentView__transitionStyle_transitionStyleview_view(Parameter.value(`transitionStyle`), Parameter.value(`view`))) as? (UIModalTransitionStyle, any View) -> Void - perform?(`transitionStyle`, `view`) + open func presentView(transitionStyle: UIModalTransitionStyle, view: any View, completion: (() -> Void)?) { + addInvocation(.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter.value(`transitionStyle`), Parameter.value(`view`), Parameter<(() -> Void)?>.value(`completion`))) + let perform = methodPerformValue(.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter.value(`transitionStyle`), Parameter.value(`view`), Parameter<(() -> Void)?>.value(`completion`))) as? (UIModalTransitionStyle, any View, (() -> Void)?) -> Void + perform?(`transitionStyle`, `view`, `completion`) } open func presentView(transitionStyle: UIModalTransitionStyle, animated: Bool, content: () -> any View) { @@ -1413,7 +1414,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) - case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) + case m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) case m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(Parameter, Parameter, Parameter<() -> any View>) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -1493,10 +1494,11 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectiontapped, rhs: rhsNextsectiontapped, with: matcher), lhsNextsectiontapped, rhsNextsectiontapped, "nextSectionTapped")) return Matcher.ComparisonResult(results) - case (.m_presentView__transitionStyle_transitionStyleview_view(let lhsTransitionstyle, let lhsView), .m_presentView__transitionStyle_transitionStyleview_view(let rhsTransitionstyle, let rhsView)): + case (.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(let lhsTransitionstyle, let lhsView, let lhsCompletion), .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(let rhsTransitionstyle, let rhsView, let rhsCompletion)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTransitionstyle, rhs: rhsTransitionstyle, with: matcher), lhsTransitionstyle, rhsTransitionstyle, "transitionStyle")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsView, rhs: rhsView, with: matcher), lhsView, rhsView, "view")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCompletion, rhs: rhsCompletion, with: matcher), lhsCompletion, rhsCompletion, "completion")) return Matcher.ComparisonResult(results) case (.m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(let lhsTransitionstyle, let lhsAnimated, let lhsContent), .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(let rhsTransitionstyle, let rhsAnimated, let rhsContent)): @@ -1525,7 +1527,7 @@ open class BaseRouterMock: BaseRouter, Mock { case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue - case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue + case let .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case let .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue } } @@ -1545,7 +1547,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" - case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" + case .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" case .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content: return ".presentView(transitionStyle:animated:content:)" } } @@ -1579,7 +1581,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showWebBrowser(title: Parameter, url: Parameter) -> Verify { return Verify(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} - public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} + public static func presentView(transitionStyle: Parameter, view: Parameter, completion: Parameter<(() -> Void)?>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`))} public static func presentView(transitionStyle: Parameter, animated: Parameter, content: Parameter<() -> any View>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(`transitionStyle`, `animated`, `content`))} } @@ -1629,8 +1631,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>, perform: @escaping (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`), performs: perform) } - public static func presentView(transitionStyle: Parameter, view: Parameter, perform: @escaping (UIModalTransitionStyle, any View) -> Void) -> Perform { - return Perform(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`), performs: perform) + public static func presentView(transitionStyle: Parameter, view: Parameter, completion: Parameter<(() -> Void)?>, perform: @escaping (UIModalTransitionStyle, any View, (() -> Void)?) -> Void) -> Perform { + return Perform(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`), performs: perform) } public static func presentView(transitionStyle: Parameter, animated: Parameter, content: Parameter<() -> any View>, perform: @escaping (UIModalTransitionStyle, Bool, () -> any View) -> Void) -> Perform { return Perform(method: .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(`transitionStyle`, `animated`, `content`), performs: perform) diff --git a/Core/Core/Assets.xcassets/CourseDates/sync_to_calendar.imageset/Contents.json b/Core/Core/Assets.xcassets/CourseDates/sync_to_calendar.imageset/Contents.json new file mode 100644 index 000000000..66cf699c0 --- /dev/null +++ b/Core/Core/Assets.xcassets/CourseDates/sync_to_calendar.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "placeholder-icon.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Core/Core/Assets.xcassets/CourseDates/sync_to_calendar.imageset/placeholder-icon.svg b/Core/Core/Assets.xcassets/CourseDates/sync_to_calendar.imageset/placeholder-icon.svg new file mode 100644 index 000000000..a46ab0c72 --- /dev/null +++ b/Core/Core/Assets.xcassets/CourseDates/sync_to_calendar.imageset/placeholder-icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Core/Core/Configuration/BaseRouter.swift b/Core/Core/Configuration/BaseRouter.swift index 2c133f529..e2a2a714b 100644 --- a/Core/Core/Configuration/BaseRouter.swift +++ b/Core/Core/Configuration/BaseRouter.swift @@ -55,7 +55,7 @@ public protocol BaseRouter { nextSectionTapped: @escaping () -> Void ) - func presentView(transitionStyle: UIModalTransitionStyle, view: any View) + func presentView(transitionStyle: UIModalTransitionStyle, view: any View, completion: (() -> Void)?) func presentView(transitionStyle: UIModalTransitionStyle, animated: Bool, content: () -> any View) @@ -121,7 +121,7 @@ open class BaseRouterMock: BaseRouter { nextSectionTapped: @escaping () -> Void ) {} - public func presentView(transitionStyle: UIModalTransitionStyle, view: any View) {} + public func presentView(transitionStyle: UIModalTransitionStyle, view: any View, completion: (() -> Void)?) {} public func presentView(transitionStyle: UIModalTransitionStyle, animated: Bool, content: () -> any View) {} diff --git a/Core/Core/Domain/Model/CourseBlockModel.swift b/Core/Core/Domain/Model/CourseBlockModel.swift index 3d8ae7a4f..53aa83fe2 100644 --- a/Core/Core/Domain/Model/CourseBlockModel.swift +++ b/Core/Core/Domain/Model/CourseBlockModel.swift @@ -63,7 +63,13 @@ public struct CourseStructure: Equatable { public func totalVideosSizeInGb(downloadQuality: DownloadQuality) -> Double { Double(totalVideosSizeInBytes(downloadQuality: downloadQuality)) / 1024.0 / 1024.0 / 1024.0 } - + + public func blockWithID(courseBlockId: String) -> CourseBlock? { + let block = childs.flatMap { + $0.childs.flatMap { $0.childs.flatMap { $0.childs.compactMap { $0 } } } + }.filter { $0.id == courseBlockId }.first + return block + } } public struct CourseChapter: Identifiable { @@ -185,6 +191,7 @@ public struct CourseBlock: Hashable, Identifiable { public let type: BlockType public let displayName: String public let studentUrl: String + public let webUrl: String public let subtitles: [SubtitleUrl]? public let encodedVideo: CourseBlockEncodedVideo? public let multiDevice: Bool? @@ -203,6 +210,7 @@ public struct CourseBlock: Hashable, Identifiable { type: BlockType, displayName: String, studentUrl: String, + webUrl: String, subtitles: [SubtitleUrl]? = nil, encodedVideo: CourseBlockEncodedVideo?, multiDevice: Bool? @@ -216,6 +224,7 @@ public struct CourseBlock: Hashable, Identifiable { self.type = type self.displayName = displayName self.studentUrl = studentUrl + self.webUrl = webUrl self.subtitles = subtitles self.encodedVideo = encodedVideo self.multiDevice = multiDevice diff --git a/Core/Core/SwiftGen/Assets.swift b/Core/Core/SwiftGen/Assets.swift index 50f49634f..c0f29a4bf 100644 --- a/Core/Core/SwiftGen/Assets.swift +++ b/Core/Core/SwiftGen/Assets.swift @@ -34,6 +34,7 @@ public enum CoreAssets { public static let lockIcon = ImageAsset(name: "lock_icon") public static let lockWithWatchIcon = ImageAsset(name: "lock_with_watch_icon") public static let schoolCapIcon = ImageAsset(name: "school_cap_icon") + public static let syncToCalendar = ImageAsset(name: "sync_to_calendar") public static let bookCircle = ImageAsset(name: "book.circle") public static let bubbleLeftCircle = ImageAsset(name: "bubble.left.circle") public static let docCircle = ImageAsset(name: "doc.circle") diff --git a/Core/Core/SwiftGen/Strings.swift b/Core/Core/SwiftGen/Strings.swift index de944968e..dd7f3daba 100644 --- a/Core/Core/SwiftGen/Strings.swift +++ b/Core/Core/SwiftGen/Strings.swift @@ -12,6 +12,8 @@ import Foundation public enum CoreLocalization { /// Done public static let done = CoreLocalization.tr("Localizable", "DONE", fallback: "Done") + /// View in Safari + public static let openInBrowser = CoreLocalization.tr("Localizable", "OPEN_IN_BROWSER", fallback: "View in Safari") /// The user canceled the sign-in flow. public static let socialSignCanceled = CoreLocalization.tr("Localizable", "SOCIAL_SIGN_CANCELED", fallback: "The user canceled the sign-in flow.") /// Tomorrow @@ -23,6 +25,10 @@ public enum CoreLocalization { public enum Alert { /// ACCEPT public static let accept = CoreLocalization.tr("Localizable", "ALERT.ACCEPT", fallback: "ACCEPT") + /// Add + public static let add = CoreLocalization.tr("Localizable", "ALERT.ADD", fallback: "Add") + /// Remove course calendar + public static let calendarShiftPromptRemoveCourseCalendar = CoreLocalization.tr("Localizable", "ALERT.CALENDAR_SHIFT_PROMPT_REMOVE_COURSE_CALENDAR", fallback: "Remove course calendar") /// CANCEL public static let cancel = CoreLocalization.tr("Localizable", "ALERT.CANCEL", fallback: "CANCEL") /// DELETE @@ -33,6 +39,8 @@ public enum CoreLocalization { public static let leave = CoreLocalization.tr("Localizable", "ALERT.LEAVE", fallback: "Leave") /// Log out public static let logout = CoreLocalization.tr("Localizable", "ALERT.LOGOUT", fallback: "Log out") + /// Remove + public static let remove = CoreLocalization.tr("Localizable", "ALERT.REMOVE", fallback: "Remove") } public enum Courseware { /// Back to outline @@ -41,6 +49,8 @@ public enum CoreLocalization { public static let `continue` = CoreLocalization.tr("Localizable", "COURSEWARE.CONTINUE", fallback: "Continue") /// Course content public static let courseContent = CoreLocalization.tr("Localizable", "COURSEWARE.COURSE_CONTENT", fallback: "Course content") + /// This interactive component isn't yet available on mobile. + public static let courseContentNotAvailable = CoreLocalization.tr("Localizable", "COURSEWARE.COURSE_CONTENT_NOT_AVAILABLE", fallback: "This interactive component isn't yet available on mobile.") /// Course units public static let courseUnits = CoreLocalization.tr("Localizable", "COURSEWARE.COURSE_UNITS", fallback: "Course units") /// Finish diff --git a/Core/Core/View/Base/AlertView.swift b/Core/Core/View/Base/AlertView.swift index 75ff3f8e3..3b8ec66e7 100644 --- a/Core/Core/View/Base/AlertView.swift +++ b/Core/Core/View/Base/AlertView.swift @@ -15,12 +15,16 @@ public enum AlertViewType: Equatable { case leaveProfile case deleteVideo case deepLink + case addCalendar + case removeCalendar + case updateCalendar + case calendarAdded var contentPadding: CGFloat { switch self { - case .`default`: + case .`default`, .calendarAdded: return 16 - case .action, .logOut, .leaveProfile, .deleteVideo, .deepLink: + case .action, .logOut, .leaveProfile, .deleteVideo, .deepLink, .addCalendar, .removeCalendar, .updateCalendar: return 36 } } @@ -31,6 +35,7 @@ public struct AlertView: View { private var alertTitle: String private var alertMessage: String private var nextSectionName: String? + private var positiveAction: String private var onCloseTapped: (() -> Void) = {} private var okTapped: (() -> Void) = {} private var nextSectionTapped: (() -> Void) = {} @@ -48,6 +53,7 @@ public struct AlertView: View { ) { self.alertTitle = alertTitle self.alertMessage = alertMessage + self.positiveAction = positiveAction self.onCloseTapped = onCloseTapped self.okTapped = okTapped self.type = type @@ -69,6 +75,7 @@ public struct AlertView: View { self.nextSectionName = nextSectionName self.okTapped = okTapped self.nextSectionTapped = nextSectionTapped + self.positiveAction = "" type = .action(mainAction, image) } @@ -147,12 +154,18 @@ public struct AlertView: View { .multilineTextAlignment(.center) .padding(.horizontal, 40) .frame(maxWidth: 250) - case .leaveProfile, .deleteVideo, .deepLink: + case .leaveProfile, .deleteVideo, .deepLink, .addCalendar, .removeCalendar, .updateCalendar: VStack(spacing: 20) { switch type { case .deleteVideo, .deepLink: CoreAssets.warning.swiftUIImage .padding(.top, isHorizontal ? 20 : 54) + case .addCalendar, .removeCalendar, .updateCalendar: + CoreAssets.syncToCalendar.swiftUIImage + .resizable() + .scaledToFit() + .frame(width: 50, height: 50) + .padding(.top, isHorizontal ? 20 : 54) default: CoreAssets.leaveProfile.swiftUIImage .padding(.top, isHorizontal ? 20 : 54) @@ -362,6 +375,32 @@ public struct AlertView: View { primaryButtonTitle: CoreLocalization.view, secondaryButtonTitle: CoreLocalization.Alert.cancel ) + case .addCalendar: + configure( + primaryButtonTitle: CoreLocalization.Alert.add, + secondaryButtonTitle: CoreLocalization.Alert.cancel + ) + case .removeCalendar: + configure( + primaryButtonTitle: CoreLocalization.Alert.remove, + secondaryButtonTitle: CoreLocalization.Alert.cancel + ) + case .updateCalendar: + configure( + primaryButtonTitle: positiveAction, + secondaryButtonTitle: CoreLocalization.Alert.calendarShiftPromptRemoveCourseCalendar + ) + case .calendarAdded: + HStack { + StyledButton(positiveAction, action: { okTapped() }) + .frame(maxWidth: 135) + StyledButton(CoreLocalization.done, action: { onCloseTapped() }) + .frame(maxWidth: 135) + .saturation(0) + } + .padding(.leading, 10) + .padding(.trailing, 10) + .padding(.bottom, 10) } } .padding(.top, 16) diff --git a/Core/Core/en.lproj/Localizable.strings b/Core/Core/en.lproj/Localizable.strings index 230ab3676..4a4f52e84 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -23,6 +23,7 @@ "ERROR.AUTHORIZATION_FAILED" = "Authorization failed."; "COURSEWARE.COURSE_CONTENT" = "Course content"; +"COURSEWARE.COURSE_CONTENT_NOT_AVAILABLE" = "This interactive component isn't yet available on mobile."; "COURSEWARE.COURSE_UNITS" = "Course units"; "COURSEWARE.NEXT" = "Next"; "COURSEWARE.PREVIOUS" = "Prev"; @@ -52,6 +53,9 @@ "ALERT.LEAVE" = "Leave"; "ALERT.KEEP_EDITING" = "Keep editing"; "ALERT.DELETE" = "DELETE"; +"ALERT.ADD" = "Add"; +"ALERT.REMOVE" = "Remove"; +"ALERT.CALENDAR_SHIFT_PROMPT_REMOVE_COURSE_CALENDAR"="Remove course calendar"; "NO_INTERNET.OFFLINE" = "Offline"; "NO_INTERNET.DISMISS" = "Dismiss"; @@ -106,3 +110,5 @@ "TOMORROW" = "Tomorrow"; "YESTERDAY" = "Yesterday"; + +"OPEN_IN_BROWSER"="View in Safari"; diff --git a/Core/Core/uk.lproj/Localizable.strings b/Core/Core/uk.lproj/Localizable.strings index 7847ee12c..3f2e867b7 100644 --- a/Core/Core/uk.lproj/Localizable.strings +++ b/Core/Core/uk.lproj/Localizable.strings @@ -22,6 +22,7 @@ "ERROR.WIFI" = "Завантажувати файли можна лише через Wi-Fi. Ви можете змінити це в налаштуваннях."; "COURSEWARE.COURSE_CONTENT" = "Зміст курсу"; +"COURSEWARE.COURSE_CONTENT_NOT_AVAILABLE" = "This interactive component isn't yet available on mobile."; "COURSEWARE.COURSE_UNITS" = "Модулі"; "COURSEWARE.NEXT" = "Далі"; "COURSEWARE.PREVIOUS" = "Назад"; @@ -50,6 +51,9 @@ "ALERT.LOGOUT" = "Вийти"; "ALERT.LEAVE" = "Покинути"; "ALERT.KEEP_EDITING" = "Залишитись"; +"ALERT.ADD" = "Add"; +"ALERT.REMOVE" = "Remove"; +"ALERT.CALENDAR_SHIFT_PROMPT_REMOVE_COURSE_CALENDAR"="Remove course calendar"; "NO_INTERNET.OFFLINE" = "Офлайн режим"; "NO_INTERNET.DISMISS" = "Сховати"; @@ -105,3 +109,5 @@ "TOMORROW" = "Tomorrow"; "YESTERDAY" = "Yesterday"; + +"OPEN_IN_BROWSER"="View in Safari"; diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index 9de40f83f..fb7deb25b 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -67,8 +67,10 @@ 197FB8EA8F92F00A8F383D82 /* Pods_App_Course.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E5E795BD160CDA7D9C120DE6 /* Pods_App_Course.framework */; }; 975F475E2B6151FD00E5B031 /* CourseDatesMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 975F475D2B6151FD00E5B031 /* CourseDatesMock.swift */; }; 975F47602B615DA700E5B031 /* CourseStructureMock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 975F475F2B615DA700E5B031 /* CourseStructureMock.swift */; }; - 97CA95252B875EE200A9EDEA /* DatesShiftedSuccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97CA95242B875EE200A9EDEA /* DatesShiftedSuccessView.swift */; }; + 97C99C362B9A08FE004EEDE2 /* CalendarSyncProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97C99C352B9A08FE004EEDE2 /* CalendarSyncProgressView.swift */; }; + 97CA95252B875EE200A9EDEA /* DatesSuccessView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97CA95242B875EE200A9EDEA /* DatesSuccessView.swift */; }; 97E7DF0F2B7C852A00A2A09B /* DatesStatusInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97E7DF0E2B7C852A00A2A09B /* DatesStatusInfoView.swift */; }; + 97EA4D862B85034D00663F58 /* CalendarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97EA4D852B85034D00663F58 /* CalendarManager.swift */; }; B8F50317B6B830A0E520C954 /* Pods_App_Course_CourseTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 50E59D2B81E12610964282C5 /* Pods_App_Course_CourseTests.framework */; }; BA58CF5D2B3D804D005B102E /* CourseStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA58CF5C2B3D804D005B102E /* CourseStorage.swift */; }; BA58CF612B471041005B102E /* VideoDownloadQualityBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA58CF602B471041005B102E /* VideoDownloadQualityBarView.swift */; }; @@ -171,8 +173,10 @@ 831B08139890634968EDD44D /* Pods-App-Course-CourseTests.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Course-CourseTests.debugstage.xcconfig"; path = "Target Support Files/Pods-App-Course-CourseTests/Pods-App-Course-CourseTests.debugstage.xcconfig"; sourceTree = ""; }; 975F475D2B6151FD00E5B031 /* CourseDatesMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDatesMock.swift; sourceTree = ""; }; 975F475F2B615DA700E5B031 /* CourseStructureMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseStructureMock.swift; sourceTree = ""; }; - 97CA95242B875EE200A9EDEA /* DatesShiftedSuccessView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatesShiftedSuccessView.swift; sourceTree = ""; }; + 97C99C352B9A08FE004EEDE2 /* CalendarSyncProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarSyncProgressView.swift; sourceTree = ""; }; + 97CA95242B875EE200A9EDEA /* DatesSuccessView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatesSuccessView.swift; sourceTree = ""; }; 97E7DF0E2B7C852A00A2A09B /* DatesStatusInfoView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DatesStatusInfoView.swift; sourceTree = ""; }; + 97EA4D852B85034D00663F58 /* CalendarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarManager.swift; sourceTree = ""; }; 99AEF08FD75F1509863D3302 /* Pods-App-CourseDetails.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-CourseDetails.debugprod.xcconfig"; path = "Target Support Files/Pods-App-CourseDetails/Pods-App-CourseDetails.debugprod.xcconfig"; sourceTree = ""; }; 9B5D3D31A9CFA08B6C4347BD /* Pods-App-CourseDetails.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-CourseDetails.releasedev.xcconfig"; path = "Target Support Files/Pods-App-CourseDetails/Pods-App-CourseDetails.releasedev.xcconfig"; sourceTree = ""; }; 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 = ""; }; @@ -288,6 +292,7 @@ 02B6B3B828E1D12900232911 /* Data */, 02B6B3B528E1D10700232911 /* Domain */, 02EAE2CA28E1F0A700529644 /* Presentation */, + 97EA4D822B84EFA900663F58 /* Managers */, 97CA95212B875EA200A9EDEA /* Views */, 02B6B3B428E1C49400232911 /* Localizable.strings */, ); @@ -491,11 +496,20 @@ 97CA95212B875EA200A9EDEA /* Views */ = { isa = PBXGroup; children = ( - 97CA95242B875EE200A9EDEA /* DatesShiftedSuccessView.swift */, + 97C99C352B9A08FE004EEDE2 /* CalendarSyncProgressView.swift */, + 97CA95242B875EE200A9EDEA /* DatesSuccessView.swift */, ); path = Views; sourceTree = ""; }; + 97EA4D822B84EFA900663F58 /* Managers */ = { + isa = PBXGroup; + children = ( + 97EA4D852B85034D00663F58 /* CalendarManager.swift */, + ); + path = Managers; + sourceTree = ""; + }; BA58CF622B471047005B102E /* VideoDownloadQualityBarView */ = { isa = PBXGroup; children = ( @@ -860,13 +874,15 @@ 022C64D829ACEC48000F532B /* HandoutsView.swift in Sources */, 02454CA82A2619890043052A /* DiscussionView.swift in Sources */, 0265B4B728E2141D00E6EAFD /* Strings.swift in Sources */, + 97C99C362B9A08FE004EEDE2 /* CalendarSyncProgressView.swift in Sources */, BAC0E0D82B32EF03006B68A9 /* DownloadsView.swift in Sources */, - 97CA95252B875EE200A9EDEA /* DatesShiftedSuccessView.swift in Sources */, + 97CA95252B875EE200A9EDEA /* DatesSuccessView.swift in Sources */, DB7D6EAC2ADFCAC50036BB13 /* CourseDatesView.swift in Sources */, 0766DFCC299AA7A600EBEF6A /* YouTubeVideoPlayer.swift in Sources */, 022F8E162A1DFBC6008EFAB9 /* YouTubeVideoPlayerViewModel.swift in Sources */, 02F175372A4DAFD20019CD70 /* CourseAnalytics.swift in Sources */, 02B6B3BE28E1D15C00232911 /* CourseEndpoint.swift in Sources */, + 97EA4D862B85034D00663F58 /* CalendarManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index 1598a05df..ac980c540 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -213,6 +213,7 @@ public class CourseRepository: CourseRepositoryProtocol { type: BlockType(rawValue: block.type) ?? .unknown, displayName: block.displayName, studentUrl: block.studentUrl, + webUrl: block.webUrl, subtitles: subtitles, encodedVideo: .init( fallback: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.fallback), @@ -420,6 +421,7 @@ And there are various ways of describing it-- call it oral poetry or type: BlockType(rawValue: block.type) ?? .unknown, displayName: block.displayName, studentUrl: block.studentUrl, + webUrl: block.webUrl, subtitles: subtitles, encodedVideo: .init( fallback: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.fallback), diff --git a/Course/Course/Data/Model/Data_CourseOutlineResponse.swift b/Course/Course/Data/Model/Data_CourseOutlineResponse.swift index f49380435..db1489638 100644 --- a/Course/Course/Data/Model/Data_CourseOutlineResponse.swift +++ b/Course/Course/Data/Model/Data_CourseOutlineResponse.swift @@ -54,6 +54,7 @@ public extension DataLayer { public let graded: Bool public let completion: Double? public let studentUrl: String + public let webUrl: String public let type: String public let displayName: String public let descendants: [String]? @@ -67,6 +68,7 @@ public extension DataLayer { graded: Bool, completion: Double?, studentUrl: String, + webUrl: String, type: String, displayName: String, descendants: [String]?, @@ -79,6 +81,7 @@ public extension DataLayer { self.graded = graded self.completion = completion self.studentUrl = studentUrl + self.webUrl = webUrl self.type = type self.displayName = displayName self.descendants = descendants @@ -91,6 +94,7 @@ public extension DataLayer { case id, type, descendants, graded, completion case blockId = "block_id" case studentUrl = "student_view_url" + case webUrl = "lms_web_url" case displayName = "display_name" case userViewData = "student_view_data" case allSources = "all_sources" diff --git a/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents b/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents index ae4e31bf2..504919fac 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 @@ - + @@ -13,6 +13,7 @@ + diff --git a/Course/Course/Domain/Model/CourseDates.swift b/Course/Course/Domain/Model/CourseDates.swift index 9a16160d3..966899cb9 100644 --- a/Course/Course/Domain/Model/CourseDates.swift +++ b/Course/Course/Domain/Model/CourseDates.swift @@ -55,6 +55,13 @@ public struct CourseDates { return statusDatesBlocks } + + var dateBlocks: [Date: [CourseDateBlock]] { + return courseDateBlocks.reduce(into: [:]) { result, block in + let date = block.date + result[date, default: []].append(block) + } + } } extension Date { @@ -280,3 +287,17 @@ public enum CompletionStatus: String { case nextWeek = "Next Week" case upcoming = "Upcoming" } + +extension Array { + mutating func modifyForEach(_ body: (_ element: inout Element) -> Void) { + for index in indices { + modifyElement(atIndex: index) { body(&$0) } + } + } + + mutating func modifyElement(atIndex index: Index, _ modifyElement: (_ element: inout Element) -> Void) { + var element = self[index] + modifyElement(&element) + self[index] = element + } +} diff --git a/Course/Course/Managers/CalendarManager.swift b/Course/Course/Managers/CalendarManager.swift new file mode 100644 index 000000000..118880aef --- /dev/null +++ b/Course/Course/Managers/CalendarManager.swift @@ -0,0 +1,480 @@ +// +// CalendarManager.swift +// Course +// +// Created by Shafqat Muneer on 2/20/24. +// + +import Foundation +import EventKit +import Theme +import Core +import BranchSDK + +enum CalendarDeepLinkType: String { + case courseComponent = "course_component" +} + +private enum CalendarDeepLinkKeys: String, RawStringExtractable { + case courseID = "course_id" + case screenName = "screen_name" + case componentID = "component_id" +} + +struct CourseCalendar: Codable { + var identifier: String + let courseID: String + let title: String + var isOn: Bool + var modalPresented: Bool +} + +class CalendarManager: NSObject { + + private let courseName: String + private let courseID: String + private let courseStructure: CourseStructure? + private let config: ConfigProtocol + + private let eventStore = EKEventStore() + private let iCloudCalendar = "icloud" + private let alertOffset = -1 + private let calendarKey = "CalendarEntries" + + private var localCalendar: EKCalendar? { + if authorizationStatus != .authorized { return nil } + + var calendars = eventStore.calendars(for: .event).filter { $0.title == calendarName } + + if calendars.isEmpty { + return nil + } else { + let calendar = calendars.removeLast() + // calendars.removeLast() pop the element from array and after that, + // following is run on remaing members of array to remove them + // calendar app, if they had been added. + calendars.forEach { try? eventStore.removeCalendar($0, commit: true) } + + return calendar + } + } + + private let calendarColor = Theme.Colors.accentColor + + private var calendarSource: EKSource? { + eventStore.refreshSourcesIfNecessary() + + let iCloud = eventStore.sources.first( + where: { $0.sourceType == .calDAV && $0.title.localizedCaseInsensitiveContains(iCloudCalendar) }) + let local = eventStore.sources.first(where: { $0.sourceType == .local }) + let fallback = eventStore.defaultCalendarForNewEvents?.source + + return iCloud ?? local ?? fallback + } + + private func calendar() -> EKCalendar { + let calendar = EKCalendar(for: .event, eventStore: eventStore) + calendar.title = calendarName + calendar.cgColor = calendarColor.cgColor + calendar.source = calendarSource + + return calendar + } + + var authorizationStatus: EKAuthorizationStatus { + return EKEventStore.authorizationStatus(for: .event) + } + + var calendarName: String { + return config.platformName + " - " + courseName + } + + private lazy var branchEnabled: Bool = { + return config.branch.enabled + }() + + var syncOn: Bool { + get { + if let calendarEntry = calendarEntry, + let localCalendar = localCalendar, + calendarEntry.identifier == localCalendar.calendarIdentifier { + return calendarEntry.isOn + } else if let localCalendar = localCalendar { + let courseCalendar = CourseCalendar( + identifier: localCalendar.calendarIdentifier, + courseID: courseID, + title: calendarName, + isOn: true, + modalPresented: false + ) + addOrUpdateCalendarEntry(courseCalendar: courseCalendar) + return true + } + return false + } + set { + updateCalendarState(isOn: newValue) + } + } + + var isModalPresented: Bool { + get { + return getModalPresented() + } + set { + setModalPresented(presented: newValue) + } + } + + required init(courseID: String, courseName: String, courseStructure: CourseStructure?, config: ConfigProtocol) { + self.courseID = courseID + self.courseName = courseName + self.courseStructure = courseStructure + self.config = config + } + + func requestAccess(completion: @escaping (Bool, EKAuthorizationStatus, EKAuthorizationStatus) -> Void) { + let previousStatus = EKEventStore.authorizationStatus(for: .event) + let requestHandler: (Bool, Error?) -> Void = { [weak self] access, _ in + self?.eventStore.reset() + let currentStatus = EKEventStore.authorizationStatus(for: .event) + DispatchQueue.main.async { + completion(access, previousStatus, currentStatus) + } + } + + if #available(iOS 17.0, *) { + eventStore.requestFullAccessToEvents { access, error in + requestHandler(access, error) + } + } else { + eventStore.requestAccess(to: .event) { access, error in + requestHandler(access, error) + } + } + } + + private func generateCourseCalendar() -> Bool { + guard localCalendar == nil else { return true } + do { + let newCalendar = calendar() + try eventStore.saveCalendar(newCalendar, commit: true) + + let courseCalendar: CourseCalendar + + if var calendarEntry = calendarEntry { + calendarEntry.identifier = newCalendar.calendarIdentifier + courseCalendar = calendarEntry + } else { + courseCalendar = CourseCalendar( + identifier: newCalendar.calendarIdentifier, + courseID: courseID, + title: calendarName, + isOn: true, + modalPresented: false + ) + } + + addOrUpdateCalendarEntry(courseCalendar: courseCalendar) + + return true + } catch { + return false + } + } + + func removeCalendar(completion: ((Bool) -> Void)? = nil) { + guard let calendar = localCalendar else { return } + do { + try eventStore.removeCalendar(calendar, commit: true) + updateSyncSwitchStatus(isOn: false) + completion?(true) + } catch { + completion?(false) + } + } + + private func calendarEvent(for block: CourseDateBlock, generateDeepLink: Bool) -> EKEvent? { + guard !block.title.isEmpty else { return nil } + + let title = block.title + ": " + courseName + // startDate is the start date and time for the event, + // it is also being used as first alert for the event + let startDate = block.date.add(.hour, value: alertOffset) + let secondAlert = startDate.add(.day, value: alertOffset) + let endDate = block.date + var notes = "\(courseName)\n\n\(block.title)" + + if generateDeepLink && block.isAvailable && branchEnabled { + if let link = generateDeeplink(componentBlockID: block.firstComponentBlockID) { + notes += "\n\(link)" + } + } + + return generateEvent( + title: title, + startDate: startDate, + endDate: endDate, + secondAlert: secondAlert, + notes: notes + ) + } + + private func calendarEvent(for blocks: [CourseDateBlock], generateDeepLink: Bool) -> EKEvent? { + guard let block = blocks.first, !block.title.isEmpty else { return nil } + + let title = block.title + ": " + courseName + // startDate is the start date and time for the event, + // it is also being used as first alert for the event + let startDate = block.date.add(.hour, value: alertOffset) + let secondAlert = startDate.add(.day, value: alertOffset) + let endDate = block.date + let notes = "\(courseName)\n\n" + blocks.compactMap { block -> String in + if generateDeepLink && block.isAvailable && branchEnabled { + if let link = generateDeeplink(componentBlockID: block.firstComponentBlockID) { + return "\(block.title)\n\(link)" + } else { + return block.title + } + } else { + return block.title + } + }.joined(separator: "\n\n") + + return generateEvent( + title: title, + startDate: startDate, + endDate: endDate, + secondAlert: secondAlert, + notes: notes + ) + } + + private func generateDeeplink(componentBlockID: String) -> String? { + guard !componentBlockID.isEmpty else { return nil } + let branchUniversalObject = BranchUniversalObject( + canonicalIdentifier: "\(CalendarDeepLinkType.courseComponent.rawValue)/\(componentBlockID)" + ) + let dictionary: NSMutableDictionary = [ + CalendarDeepLinkKeys.screenName.rawValue: CalendarDeepLinkType.courseComponent.rawValue, + CalendarDeepLinkKeys.courseID.rawValue: courseID, + CalendarDeepLinkKeys.componentID.rawValue: componentBlockID + ] + let metadata = BranchContentMetadata() + metadata.customMetadata = dictionary + branchUniversalObject.contentMetadata = metadata + let properties = BranchLinkProperties() + if let block = courseStructure?.blockWithID(courseBlockId: componentBlockID), !block.webUrl.isEmpty { + properties.addControlParam("$desktop_url", withValue: block.webUrl) + } + return branchUniversalObject.getShortUrl(with: properties) + } + + private func generateEvent(title: String, + startDate: Date, + endDate: Date, + secondAlert: Date, + notes: String) -> EKEvent { + let event = EKEvent(eventStore: eventStore) + event.title = title + event.startDate = startDate + event.endDate = endDate + event.calendar = localCalendar + event.notes = notes + + if startDate > Date() { + let alarm = EKAlarm(absoluteDate: startDate) + event.addAlarm(alarm) + } + + if secondAlert > Date() { + let alarm = EKAlarm(absoluteDate: secondAlert) + event.addAlarm(alarm) + } + + return event + } + + private func addEvent(event: EKEvent) { + if !alreadyExist(event: event) { + try? eventStore.save(event, span: .thisEvent) + } + } + + private func alreadyExist(event eventToAdd: EKEvent) -> Bool { + guard let courseCalendar = calendarEntry else { return false } + let calendars = eventStore.calendars(for: .event).filter { $0.calendarIdentifier == courseCalendar.identifier } + let predicate = eventStore.predicateForEvents( + withStart: eventToAdd.startDate, + end: eventToAdd.endDate, + calendars: calendars + ) + let existingEvents = eventStore.events(matching: predicate) + + return existingEvents.contains { event -> Bool in + return event.title == eventToAdd.title + && event.startDate == eventToAdd.startDate + && event.endDate == eventToAdd.endDate + } + } + + private func setModalPresented(presented: Bool) { + guard var calendars = courseCalendars(), + let index = calendars.firstIndex(where: { $0.title == calendarName }) + else { return } + + calendars.modifyElement(atIndex: index) { element in + element.modalPresented = presented + } + + saveCalendarEntry(calendars: calendars) + } + + private func getModalPresented() -> Bool { + guard let calendars = courseCalendars(), + let calendar = calendars.first(where: { $0.title == calendarName }) + else { return false } + + return calendar.modalPresented + } + + private func removeCalendarEntry() { + guard var calendars = courseCalendars() else { return } + + if let index = calendars.firstIndex(where: { $0.title == calendarName }) { + calendars.remove(at: index) + } + + saveCalendarEntry(calendars: calendars) + } + + private func updateSyncSwitchStatus(isOn: Bool) { + guard var calendars = courseCalendars() else { return } + + if let index = calendars.firstIndex(where: { $0.title == calendarName }) { + calendars.modifyElement(atIndex: index) { element in + element.isOn = isOn + } + } + + saveCalendarEntry(calendars: calendars) + } + + private var calendarEntry: CourseCalendar? { + guard let calendars = courseCalendars() else { return nil } + return calendars.first(where: { $0.title == calendarName }) + } +} + +extension CalendarManager { + func addEventsToCalendar(for dateBlocks: [Date: [CourseDateBlock]], completion: @escaping (Bool) -> Void) { + if !generateCourseCalendar() { + completion(false) + return + } + + DispatchQueue.global().async { [weak self] in + guard let weakSelf = self else { return } + let events = weakSelf.generateEvents(for: dateBlocks, generateDeepLink: true) + + if events.isEmpty { + //Ideally this shouldn't happen, but in any case if this happen so lets remove the calendar + weakSelf.removeCalendar() + completion(false) + } else { + events.forEach { event in weakSelf.addEvent(event: event) } + do { + try weakSelf.eventStore.commit() + DispatchQueue.main.async { + completion(true) + } + } catch { + DispatchQueue.main.async { + completion(false) + } + } + } + } + } + + func checkIfEventsShouldBeShifted(for dateBlocks: [Date: [CourseDateBlock]]) -> Bool { + guard calendarEntry != nil else { return true } + + let events = generateEvents(for: dateBlocks, generateDeepLink: false) + let allEvents = events.allSatisfy { alreadyExist(event: $0) } + + return !allEvents + } + + private func generateEvents(for dateBlocks: [Date: [CourseDateBlock]], generateDeepLink: Bool) -> [EKEvent] { + var events: [EKEvent] = [] + dateBlocks.forEach { item in + let blocks = item.value + + if blocks.count > 1 { + if let generatedEvent = calendarEvent(for: blocks, generateDeepLink: generateDeepLink) { + events.append(generatedEvent) + } + } else { + if let block = blocks.first { + if let generatedEvent = calendarEvent(for: block, generateDeepLink: generateDeepLink) { + events.append(generatedEvent) + } + } + } + } + + return events + } + + private func addOrUpdateCalendarEntry(courseCalendar: CourseCalendar) { + var calenders: [CourseCalendar] = [] + + if let decodedCalendars = courseCalendars() { + calenders = decodedCalendars + } + + if let index = calenders.firstIndex(where: { $0.title == calendarName }) { + calenders.modifyElement(atIndex: index) { element in + element = courseCalendar + } + } else { + calenders.append(courseCalendar) + } + + saveCalendarEntry(calendars: calenders) + } + + private func updateCalendarState(isOn: Bool) { + guard var calendars = courseCalendars(), + let index = calendars.firstIndex(where: { $0.title == calendarName }) + else { return } + + calendars.modifyElement(atIndex: index) { element in + element.isOn = isOn + } + + saveCalendarEntry(calendars: calendars) + } + + private func courseCalendars() -> [CourseCalendar]? { + guard let data = UserDefaults.standard.data(forKey: calendarKey), + let courseCalendars = try? PropertyListDecoder().decode([CourseCalendar].self, from: data) + else { return nil } + + return courseCalendars + } + + private func saveCalendarEntry(calendars: [CourseCalendar]) { + guard let data = try? PropertyListEncoder().encode(calendars) else { return } + + UserDefaults.standard.set(data, forKey: calendarKey) + UserDefaults.standard.synchronize() + } +} + +fileprivate extension Date { + func add(_ unit: Calendar.Component, value: Int) -> Date { + return Calendar.current.date(byAdding: unit, value: value, to: self) ?? self + } +} diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 2bd2708fe..d08bdbed4 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -127,7 +127,7 @@ public struct CourseContainerView: View { CourseDatesView( courseID: courseID, viewModel: Container.shared.resolve(CourseDatesViewModel.self, - argument: courseID)! + arguments: courseID, title)! ) .tabItem { tab.image diff --git a/Course/Course/Presentation/CourseRouter.swift b/Course/Course/Presentation/CourseRouter.swift index d4cd7c68a..607c51ac5 100644 --- a/Course/Course/Presentation/CourseRouter.swift +++ b/Course/Course/Presentation/CourseRouter.swift @@ -53,7 +53,8 @@ public protocol CourseRouter: BaseRouter { func showCourseComponent( componentID: String, - courseStructure: CourseStructure + courseStructure: CourseStructure, + blockLink: String ) func showDownloads( @@ -111,7 +112,8 @@ public class CourseRouterMock: BaseRouterMock, CourseRouter { public func showCourseComponent( componentID: String, - courseStructure: CourseStructure + courseStructure: CourseStructure, + blockLink: String ) {} public func showDownloads( diff --git a/Course/Course/Presentation/Dates/CourseDatesView.swift b/Course/Course/Presentation/Dates/CourseDatesView.swift index 491497944..612e0fb32 100644 --- a/Course/Course/Presentation/Dates/CourseDatesView.swift +++ b/Course/Course/Presentation/Dates/CourseDatesView.swift @@ -40,8 +40,29 @@ public struct CourseDatesView: View { } } - if viewModel.dueDatesShifted { - DatesShiftedSuccessView(selectedTab: .dates, courseDatesViewModel: viewModel) + switch viewModel.eventState { + case .addedCalendar: + showDatesSuccessView( + title: CourseLocalization.CourseDates.calendarEvents, + message: CourseLocalization.CourseDates.calendarEventsAdded + ) + case .removedCalendar: + showDatesSuccessView( + title: CourseLocalization.CourseDates.calendarEvents, + message: CourseLocalization.CourseDates.calendarEventsRemoved + ) + case .updatedCalendar: + showDatesSuccessView( + title: CourseLocalization.CourseDates.calendarEvents, + message: CourseLocalization.CourseDates.calendarEventsUpdated + ) + case .shiftedDueDates: + showDatesSuccessView( + title: CourseLocalization.CourseDates.toastSuccessTitle, + message: CourseLocalization.CourseDates.toastSuccessMessage + ) + default: + EmptyView() } if viewModel.showError { @@ -68,6 +89,25 @@ public struct CourseDatesView: View { ) .frame(maxWidth: .infinity, maxHeight: .infinity) } + + private func showDatesSuccessView(title: String, message: String) -> some View { + if viewModel.eventState == .shiftedDueDates { + return DatesSuccessView( + title: title, + message: message, + selectedTab: .dates, + courseDatesViewModel: viewModel + ) + } else { + return DatesSuccessView( + title: title, + message: message, + selectedTab: .dates + ) { + viewModel.resetEventState() + } + } + } } struct Line: Shape { @@ -108,6 +148,9 @@ struct CourseDateListView: View { ScrollView { VStack(alignment: .leading, spacing: 0) { if !courseDates.hasEnded { + CalendarSyncView(courseID: courseID, viewModel: viewModel) + .padding(.bottom, 16) + DatesStatusInfoView( datesBannerInfo: courseDates.datesBannerInfo, courseID: courseID, @@ -324,7 +367,10 @@ struct StyleBlock: View { .onTapGesture { if block.canShowLink && !block.firstComponentBlockID.isEmpty { Task { - await viewModel.showCourseDetails(componentID: block.firstComponentBlockID) + await viewModel.showCourseDetails( + componentID: block.firstComponentBlockID, + blockLink: block.link + ) } viewModel.logdateComponentTapped(block: block, supported: true) } else { @@ -340,6 +386,42 @@ struct StyleBlock: View { } } +struct CalendarSyncView: View { + let courseID: String + @ObservedObject var viewModel: CourseDatesViewModel + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Spacer() + HStack { + CoreAssets.syncToCalendar.swiftUIImage + Text(CourseLocalization.CourseDates.syncToCalendar) + .font(Theme.Fonts.titleMedium) + .foregroundColor(Theme.Colors.textPrimary) + Toggle("", isOn: .constant(viewModel.isOn)) + .toggleStyle(SwitchToggleStyle(tint: Theme.Colors.accentButtonColor)) + .padding(.trailing, 0) + .onTapGesture { + viewModel.calendarState = !viewModel.isOn + } + } + .padding(.horizontal, 16) + + Text(CourseLocalization.CourseDates.syncToCalendarMessage) + .frame(maxWidth: .infinity, alignment: .leading) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + .padding(.horizontal, 16) + Spacer() + } + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.datesSectionStroke, lineWidth: 2) + ) + .background(Theme.Colors.datesSectionBackground) + } +} + fileprivate extension BlockStatus { var title: String { switch self { @@ -400,8 +482,11 @@ struct CourseDatesView_Previews: PreviewProvider { router: CourseRouterMock(), cssInjector: CSSInjectorMock(), connectivity: Connectivity(), + config: ConfigMock(), courseID: "", - analytics: CourseAnalyticsMock()) + courseName: "", + analytics: CourseAnalyticsMock() + ) CourseDatesView( courseID: "", diff --git a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift index 5aaee1da4..f0cb1b8cd 100644 --- a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift +++ b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift @@ -11,11 +11,29 @@ import SwiftUI public class CourseDatesViewModel: ObservableObject { + enum EventState { + case addedCalendar + case removedCalendar + case updatedCalendar + case shiftedDueDates + case none + } + @Published private(set) var isShowProgress = false @Published var showError: Bool = false @Published var courseDates: CourseDates? - @Published var dueDatesShifted: Bool = false + @Published var isOn: Bool = false + @Published var eventState: EventState? + lazy var calendar: CalendarManager = { + return CalendarManager( + courseID: courseID, + courseName: courseStructure?.displayName ?? config.platformName, + courseStructure: courseStructure, + config: config + ) + }() + var errorMessage: String? { didSet { withAnimation { @@ -24,11 +42,27 @@ public class CourseDatesViewModel: ObservableObject { } } + var calendarState: Bool { + get { + return calendar.syncOn + } + set { + if newValue { + handleCalendar() + } else { + showRemoveCalendarAlert() + } + } + } + private let interactor: CourseInteractorProtocol let cssInjector: CSSInjector let router: CourseRouter let connectivity: ConnectivityProtocol + let config: ConfigProtocol let courseID: String + let courseName: String + var courseStructure: CourseStructure? let analytics: CourseAnalytics public init( @@ -36,14 +70,18 @@ public class CourseDatesViewModel: ObservableObject { router: CourseRouter, cssInjector: CSSInjector, connectivity: ConnectivityProtocol, + config: ConfigProtocol, courseID: String, + courseName: String, analytics: CourseAnalytics ) { self.interactor = interactor self.router = router self.cssInjector = cssInjector self.connectivity = connectivity + self.config = config self.courseID = courseID + self.courseName = courseName self.analytics = analytics addObservers() } @@ -69,12 +107,14 @@ public class CourseDatesViewModel: ObservableObject { isShowProgress = true do { courseDates = try await interactor.getCourseDates(courseID: courseID) + await getCourseStructure(courseID: courseID) if courseDates?.courseDateBlocks == nil { isShowProgress = false errorMessage = CoreLocalization.Error.unknownError return } isShowProgress = false + addCourseEventsIfNecessary() } catch let error { isShowProgress = false if error.isInternetError || error is NoCachedDataError { @@ -85,18 +125,29 @@ public class CourseDatesViewModel: ObservableObject { } } - func showCourseDetails(componentID: String) async { + func showCourseDetails(componentID: String, blockLink: String) async { do { let courseStructure = try await interactor.getLoadedCourseBlocks(courseID: courseID) router.showCourseComponent( componentID: componentID, - courseStructure: courseStructure + courseStructure: courseStructure, + blockLink: blockLink ) } catch _ { errorMessage = CourseLocalization.Error.componentNotFount } } + @MainActor + func getCourseStructure(courseID: String) async { + do { + courseStructure = try await interactor.getLoadedCourseBlocks(courseID: courseID) + isOn = calendarState + } catch _ { + errorMessage = CourseLocalization.Error.componentNotFount + } + } + @MainActor func shiftDueDates(courseID: String, withProgress: Bool = true, screen: DatesStatusInfoScreen, type: String) async { isShowProgress = withProgress @@ -149,14 +200,205 @@ extension CourseDatesViewModel { Task { await getCourseDates(courseID: courseID) await MainActor.run { [weak self] in - self?.dueDatesShifted = true + self?.eventState = .shiftedDueDates + } + } + } + } + + func resetEventState() { + eventState = EventState.none + } +} + +extension CourseDatesViewModel { + private func handleCalendar() { + calendar.requestAccess { [weak self] _, previousStatus, status in + guard let self else { return } + switch status { + case .authorized: + showAddCalendarAlert() + default: + isOn = false + if previousStatus == status { + self.showCalendarSettingsAlert() + } + } + } + } + + @MainActor + func addCourseEvents(trackAnalytics: Bool = true, completion: ((Bool) -> Void)? = nil) { + guard let dateBlocks = courseDates?.dateBlocks else { return } + showCalendarSyncProgressView { [weak self] in + self?.calendar.addEventsToCalendar(for: dateBlocks) { [weak self] calendarEventsAdded in + self?.isOn = calendarEventsAdded + if calendarEventsAdded { + self?.calendar.syncOn = calendarEventsAdded + self?.router.dismiss(animated: false) + self?.showEventsAddedSuccessAlert() + } + completion?(calendarEventsAdded) + } + } + } + + func removeCourseCalendar(trackAnalytics: Bool = true, completion: ((Bool) -> Void)? = nil) { + calendar.removeCalendar { [weak self] success in + guard let self else { return } + self.isOn = !success + completion?(success) + } + } + + private func showAddCalendarAlert() { + router.presentAlert( + alertTitle: CourseLocalization.CourseDates.addCalendarTitle, + alertMessage: CourseLocalization.CourseDates.addCalendarPrompt( + config.platformName, + courseName + ), + positiveAction: CoreLocalization.Alert.accept, + onCloseTapped: { [weak self] in + self?.router.dismiss(animated: true) + self?.isOn = false + self?.calendar.syncOn = false + }, + okTapped: { [weak self] in + self?.router.dismiss(animated: true) + Task { [weak self] in + await self?.addCourseEvents() } + }, + type: .addCalendar + ) + } + + private func showRemoveCalendarAlert() { + router.presentAlert( + alertTitle: CourseLocalization.CourseDates.removeCalendarTitle, + alertMessage: CourseLocalization.CourseDates.removeCalendarPrompt( + config.platformName, + courseName + ), + positiveAction: CoreLocalization.Alert.accept, + onCloseTapped: { [weak self] in + self?.router.dismiss(animated: true) + }, + okTapped: { [weak self] in + self?.router.dismiss(animated: true) + self?.removeCourseCalendar { [weak self] _ in + self?.eventState = .removedCalendar + } + + }, + type: .removeCalendar + ) + } + + private func showEventsAddedSuccessAlert() { + if calendar.isModalPresented { + eventState = .addedCalendar + return + } + calendar.isModalPresented = true + router.presentAlert( + alertTitle: "", + alertMessage: CourseLocalization.CourseDates.datesAddedAlertMessage( + calendar.calendarName + ), + positiveAction: CourseLocalization.CourseDates.calendarViewEvents, + onCloseTapped: { [weak self] in + self?.router.dismiss(animated: true) + self?.isOn = true + self?.calendar.syncOn = true + }, + okTapped: { [weak self] in + self?.router.dismiss(animated: true) + if let url = URL(string: "calshow://"), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + }, + type: .calendarAdded + ) + } + + func showCalendarSyncProgressView(completion: @escaping (() -> Void)) { + router.presentView( + transitionStyle: .crossDissolve, + view: CalendarSyncProgressView( + title: CourseLocalization.CourseDates.calendarSyncMessage + ), + completion: completion + ) + } + + @MainActor + private func addCourseEventsIfNecessary() { + Task { + if calendar.syncOn && calendar.checkIfEventsShouldBeShifted(for: courseDates?.dateBlocks ?? [:]) { + showCalendarEventShiftAlert() } } } - func resetDueDatesShiftedFlag() { - dueDatesShifted = false + @MainActor + private func showCalendarEventShiftAlert() { + router.presentAlert( + alertTitle: CourseLocalization.CourseDates.calendarOutOfDate, + alertMessage: CourseLocalization.CourseDates.calendarShiftMessage, + positiveAction: CourseLocalization.CourseDates.calendarShiftPromptUpdateNow, + onCloseTapped: { [weak self] in + // Remove course calendar + self?.router.dismiss(animated: true) + self?.removeCourseCalendar { [weak self] _ in + self?.eventState = .removedCalendar + } + }, + okTapped: { [weak self] in + // Update Calendar Now + self?.router.dismiss(animated: true) + self?.removeCourseCalendar(trackAnalytics: false) { success in + self?.isOn = !success + self?.calendar.syncOn = false + self?.addCourseEvents(trackAnalytics: false) { [weak self] calendarEventsAdded in + self?.isOn = calendarEventsAdded + if calendarEventsAdded { + self?.calendar.syncOn = calendarEventsAdded + self?.eventState = .updatedCalendar + } + } + } + }, + type: .updateCalendar + ) + } + + private func showCalendarSettingsAlert() { + guard let settingsURL = URL(string: UIApplication.openSettingsURLString) else { + return + } + + router.presentAlert( + alertTitle: CourseLocalization.CourseDates.settings, + alertMessage: CourseLocalization.CourseDates.calendarPermissionNotDetermined(config.platformName), + positiveAction: CourseLocalization.CourseDates.openSettings, + onCloseTapped: { [weak self] in + self?.isOn = false + self?.router.dismiss(animated: true) + }, + okTapped: { [weak self] in + self?.isOn = false + if UIApplication.shared.canOpenURL(settingsURL) { + UIApplication.shared.open(settingsURL, options: [:], completionHandler: nil) + } + self?.router.dismiss(animated: true) + }, + type: .default( + positiveAction: CourseLocalization.CourseDates.openSettings, + image: CoreAssets.syncToCalendar.swiftUIImage + ) + ) } func logdateComponentTapped(block: CourseDateBlock, supported: Bool) { diff --git a/Course/Course/Presentation/Outline/ContinueWithView.swift b/Course/Course/Presentation/Outline/ContinueWithView.swift index 94b8bbaf7..9525a9d7b 100644 --- a/Course/Course/Presentation/Outline/ContinueWithView.swift +++ b/Course/Course/Presentation/Outline/ContinueWithView.swift @@ -85,6 +85,7 @@ struct ContinueWithView_Previews: PreviewProvider { type: .html, displayName: "Continue lesson", studentUrl: "", + webUrl: "", encodedVideo: nil, multiDevice: true ), @@ -97,6 +98,7 @@ struct ContinueWithView_Previews: PreviewProvider { type: .html, displayName: "Continue lesson", studentUrl: "", + webUrl: "", encodedVideo: nil, multiDevice: false diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 3c85b1fbc..cf2ec1854 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -154,7 +154,12 @@ public struct CourseOutlineView: View { .accessibilityAction {} if viewModel.dueDatesShifted && !isVideo { - DatesShiftedSuccessView(selectedTab: .course, courseContainerViewModel: viewModel) { + DatesSuccessView( + title: CourseLocalization.CourseDates.toastSuccessTitle, + message: CourseLocalization.CourseDates.toastSuccessMessage, + selectedTab: .course, + courseContainerViewModel: viewModel + ) { selection = dateTabIndex } } diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift index c9f7ee07b..5d33fbd69 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift @@ -39,6 +39,7 @@ struct CourseVerticalImageView_Previews: PreviewProvider { type: .video, displayName: "Block 1", studentUrl: "", + webUrl: "", encodedVideo: nil, multiDevice: true ) @@ -55,6 +56,7 @@ struct CourseVerticalImageView_Previews: PreviewProvider { type: .problem, displayName: "Block 1", studentUrl: "", + webUrl: "", encodedVideo: nil, multiDevice: false ) @@ -70,6 +72,7 @@ struct CourseVerticalImageView_Previews: PreviewProvider { type: .discussion, displayName: "Block 1", studentUrl: "", + webUrl: "", encodedVideo: nil, multiDevice: true ) @@ -85,6 +88,7 @@ struct CourseVerticalImageView_Previews: PreviewProvider { type: .html, displayName: "Block 1", studentUrl: "", + webUrl: "", encodedVideo: nil, multiDevice: false ) @@ -100,6 +104,7 @@ struct CourseVerticalImageView_Previews: PreviewProvider { type: .unknown, displayName: "Block 1", studentUrl: "", + webUrl: "", encodedVideo: nil, multiDevice: true ) diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index de51d8c03..e38c22879 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -450,6 +450,7 @@ struct CourseUnitView_Previews: PreviewProvider { type: .video, displayName: "Lesson 1", studentUrl: "", + webUrl: "", encodedVideo: nil, multiDevice: true ), @@ -463,6 +464,7 @@ struct CourseUnitView_Previews: PreviewProvider { type: .video, displayName: "Lesson 2", studentUrl: "2", + webUrl: "2", encodedVideo: nil, multiDevice: false ), @@ -476,6 +478,7 @@ struct CourseUnitView_Previews: PreviewProvider { type: .unknown, displayName: "Lesson 3", studentUrl: "3", + webUrl: "3", encodedVideo: nil, multiDevice: true ), @@ -489,6 +492,7 @@ struct CourseUnitView_Previews: PreviewProvider { type: .unknown, displayName: "4", studentUrl: "4", + webUrl: "4", encodedVideo: nil, multiDevice: false ), diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift index db595207d..397bf332a 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift @@ -81,6 +81,7 @@ struct CourseUnitDropDownCell_Previews: PreviewProvider { type: .video, displayName: "Lesson 1", studentUrl: "", + webUrl: "", encodedVideo: nil, multiDevice: true ) diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift index d7e1a2836..f338a202f 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift @@ -55,6 +55,7 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { type: .video, displayName: "Lesson 1", studentUrl: "", + webUrl: "", encodedVideo: nil, multiDevice: true ), @@ -68,6 +69,7 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { type: .video, displayName: "Lesson 2", studentUrl: "2", + webUrl: "2", encodedVideo: nil, multiDevice: false ), @@ -81,6 +83,7 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { type: .unknown, displayName: "Lesson 3", studentUrl: "3", + webUrl: "3", encodedVideo: nil, multiDevice: true ), @@ -94,6 +97,7 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { type: .unknown, displayName: "4", studentUrl: "4", + webUrl: "4", encodedVideo: nil, multiDevice: false ) diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift index aeef714b3..805c709a1 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift @@ -68,6 +68,7 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { type: .video, displayName: "Lesson 1", studentUrl: "", + webUrl: "", encodedVideo: nil, multiDevice: true @@ -82,6 +83,7 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { type: .video, displayName: "Lesson 2", studentUrl: "2", + webUrl: "2", encodedVideo: nil, multiDevice: false @@ -96,6 +98,7 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { type: .unknown, displayName: "Lesson 3", studentUrl: "3", + webUrl: "3", encodedVideo: nil, multiDevice: true @@ -110,6 +113,7 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { type: .unknown, displayName: "4", studentUrl: "4", + webUrl: "4", encodedVideo: nil, multiDevice: false ) diff --git a/Course/Course/SwiftGen/Strings.swift b/Course/Course/SwiftGen/Strings.swift index d48da2e62..f61aef96c 100644 --- a/Course/Course/SwiftGen/Strings.swift +++ b/Course/Course/SwiftGen/Strings.swift @@ -69,16 +69,63 @@ public enum CourseLocalization { public static let videos = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.VIDEOS", fallback: "Videos") } public enum CourseDates { + /// Would you like to add the %@ calendar "%@" ? + /// You can edit or remove the course calendar any time in Calendar or Settings + public static func addCalendarPrompt(_ p1: Any, _ p2: Any) -> String { + return CourseLocalization.tr("Localizable", "COURSE_DATES.ADD_CALENDAR_PROMPT", String(describing: p1), String(describing: p2), fallback: "Would you like to add the %@ calendar \"%@\" ? \n You can edit or remove the course calendar any time in Calendar or Settings") + } + /// Add calendar + public static let addCalendarTitle = CourseLocalization.tr("Localizable", "COURSE_DATES.ADD_CALENDAR_TITLE", fallback: "Add calendar") + /// Calendar events + public static let calendarEvents = CourseLocalization.tr("Localizable", "COURSE_DATES.CALENDAR_EVENTS", fallback: "Calendar events") + /// Your course calendar has been added. + public static let calendarEventsAdded = CourseLocalization.tr("Localizable", "COURSE_DATES.CALENDAR_EVENTS_ADDED", fallback: "Your course calendar has been added.") + /// Your course calendar has been removed. + public static let calendarEventsRemoved = CourseLocalization.tr("Localizable", "COURSE_DATES.CALENDAR_EVENTS_REMOVED", fallback: "Your course calendar has been removed.") + /// Your course calendar has been updated. + public static let calendarEventsUpdated = CourseLocalization.tr("Localizable", "COURSE_DATES.CALENDAR_EVENTS_UPDATED", fallback: "Your course calendar has been updated.") + /// Your course calendar is out of date + public static let calendarOutOfDate = CourseLocalization.tr("Localizable", "COURSE_DATES.CALENDAR_OUT_OF_DATE", fallback: "Your course calendar is out of date") + /// %@ does not have calendar permission. Please go to settings and give calender permission. + public static func calendarPermissionNotDetermined(_ p1: Any) -> String { + return CourseLocalization.tr("Localizable", "COURSE_DATES.CALENDAR_PERMISSION_NOT_DETERMINED", String(describing: p1), fallback: "%@ does not have calendar permission. Please go to settings and give calender permission.") + } + /// Your course dates have been shifted and your course calendar is no longer up to date with your new schedule. + public static let calendarShiftMessage = CourseLocalization.tr("Localizable", "COURSE_DATES.CALENDAR_SHIFT_MESSAGE", fallback: "Your course dates have been shifted and your course calendar is no longer up to date with your new schedule.") + /// Update now + public static let calendarShiftPromptUpdateNow = CourseLocalization.tr("Localizable", "COURSE_DATES.CALENDAR_SHIFT_PROMPT_UPDATE_NOW", fallback: "Update now") + /// Syncing calendar... + public static let calendarSyncMessage = CourseLocalization.tr("Localizable", "COURSE_DATES.CALENDAR_SYNC_MESSAGE", fallback: "Syncing calendar...") + /// View Events + public static let calendarViewEvents = CourseLocalization.tr("Localizable", "COURSE_DATES.CALENDAR_VIEW_EVENTS", fallback: "View Events") /// Completed public static let completed = CourseLocalization.tr("Localizable", "COURSE_DATES.COMPLETED", fallback: "Completed") + /// "%@" has been added to your calendar. + public static func datesAddedAlertMessage(_ p1: Any) -> String { + return CourseLocalization.tr("Localizable", "COURSE_DATES.DATES_ADDED_ALERT_MESSAGE", String(describing: p1), fallback: "\"%@\" has been added to your calendar.") + } /// Due next public static let dueNext = CourseLocalization.tr("Localizable", "COURSE_DATES.DUE_NEXT", fallback: "Due next") /// Item Hidden public static let itemHidden = CourseLocalization.tr("Localizable", "COURSE_DATES.ITEM_HIDDEN", fallback: "Item Hidden") /// Items Hidden public static let itemsHidden = CourseLocalization.tr("Localizable", "COURSE_DATES.ITEMS_HIDDEN", fallback: "Items Hidden") + /// Open Settings + public static let openSettings = CourseLocalization.tr("Localizable", "COURSE_DATES.OPEN_SETTINGS", fallback: "Open Settings") /// Past due public static let pastDue = CourseLocalization.tr("Localizable", "COURSE_DATES.PAST_DUE", fallback: "Past due") + /// Would you like to remove the %@ calendar "%@" ? + public static func removeCalendarPrompt(_ p1: Any, _ p2: Any) -> String { + return CourseLocalization.tr("Localizable", "COURSE_DATES.REMOVE_CALENDAR_PROMPT", String(describing: p1), String(describing: p2), fallback: "Would you like to remove the %@ calendar \"%@\" ?") + } + /// Remove calendar + public static let removeCalendarTitle = CourseLocalization.tr("Localizable", "COURSE_DATES.REMOVE_CALENDAR_TITLE", fallback: "Remove calendar") + /// Settings + public static let settings = CourseLocalization.tr("Localizable", "COURSE_DATES.SETTINGS", fallback: "Settings") + /// Sync to calendar + public static let syncToCalendar = CourseLocalization.tr("Localizable", "COURSE_DATES.SYNC_TO_CALENDAR", fallback: "Sync to calendar") + /// Automatically sync all deadlines and due dates for this course to your calendar. + public static let syncToCalendarMessage = CourseLocalization.tr("Localizable", "COURSE_DATES.SYNC_TO_CALENDAR_MESSAGE", fallback: "Automatically sync all deadlines and due dates for this course to your calendar.") /// Your due dates have been successfully shifted to help you stay on track. public static let toastSuccessMessage = CourseLocalization.tr("Localizable", "COURSE_DATES.TOAST_SUCCESS_MESSAGE", fallback: "Your due dates have been successfully shifted to help you stay on track.") /// Due dates shifted diff --git a/Course/Course/Views/CalendarSyncProgressView.swift b/Course/Course/Views/CalendarSyncProgressView.swift new file mode 100644 index 000000000..e3ddbd75f --- /dev/null +++ b/Course/Course/Views/CalendarSyncProgressView.swift @@ -0,0 +1,67 @@ +// +// CalendarSyncProgressView.swift +// Core +// +// Created by Shafqat Muneer on 3/15/24. +// + +import SwiftUI +import Theme +import Core + +public struct CalendarSyncProgressView: View { + + @Environment(\.dismiss) private var dismiss + private let title: String + + public init(title: String) { + self.title = title + } + + public var body: some View { + ZStack(alignment: .center) { + Color.black.opacity(0) + .onTapGesture { + dismiss() + } + VStack(alignment: .center) { + Text(title) + .padding(.horizontal) + .padding(.top, 20) + ProgressBar(size: 40, lineWidth: 8) + .font(Theme.Fonts.titleMedium) + .padding(.top, 20) + .padding(.bottom, 20) + } + .frame(maxWidth: 250) + .background( + Theme.Shapes.cardShape + .fill(Theme.Colors.cardViewBackground) + .shadow(radius: 24) + .fixedSize(horizontal: false, vertical: false) + ) + .overlay( + RoundedRectangle(cornerRadius: 12) + .stroke( + style: .init( + lineWidth: 1, + lineCap: .round, + lineJoin: .round, + miterLimit: 1 + ) + ) + .foregroundColor(Theme.Colors.backgroundStroke) + .fixedSize(horizontal: false, vertical: false) + ) + } + .ignoresSafeArea() + } +} + +#if DEBUG +struct CalendarSyncProgressView_Previews: PreviewProvider { + static var previews: some View { + CalendarSyncProgressView(title: CourseLocalization.CourseDates.calendarSyncMessage) + } +} +#endif diff --git a/Course/Course/Views/DatesShiftedSuccessView.swift b/Course/Course/Views/DatesSuccessView.swift similarity index 66% rename from Course/Course/Views/DatesShiftedSuccessView.swift rename to Course/Course/Views/DatesSuccessView.swift index 87c2ee9b0..3adfe607c 100644 --- a/Course/Course/Views/DatesShiftedSuccessView.swift +++ b/Course/Course/Views/DatesSuccessView.swift @@ -1,5 +1,5 @@ // -// DatesShiftedSuccessView.swift +// DatesSuccessView.swift // Core // // Created by Shafqat Muneer on 2/18/24. @@ -9,27 +9,68 @@ import SwiftUI import Combine import Theme -public struct DatesShiftedSuccessView: View { +public struct DatesSuccessView: View { enum Tab { case course case dates } + private var title: String + private var message: String var selectedTab: Tab var courseDatesViewModel: CourseDatesViewModel? var courseContainerViewModel: CourseContainerViewModel? - var action: () async -> Void = {} + var action: () -> Void = {} + var dismissAction: () -> Void = {} @State private var dismiss: Bool = false + init ( + title: String, + message: String, + selectedTab: Tab, + dismissAction: @escaping () -> Void + ) { + self.title = title + self.message = message + self.selectedTab = selectedTab + self.dismissAction = dismissAction + } + + init ( + title: String, + message: String, + selectedTab: Tab, + courseDatesViewModel: CourseDatesViewModel? + ) { + self.title = title + self.message = message + self.selectedTab = selectedTab + self.courseDatesViewModel = courseDatesViewModel + } + + init ( + title: String, + message: String, + selectedTab: Tab, + courseContainerViewModel: CourseContainerViewModel?, + action: @escaping () -> Void + ) { + self.title = title + self.message = message + self.selectedTab = selectedTab + self.courseContainerViewModel = courseContainerViewModel + self.action = action + } + public var body: some View { ZStack { VStack { Spacer() VStack(alignment: .leading, spacing: 16) { HStack(alignment: .top) { - Text(CourseLocalization.CourseDates.toastSuccessTitle) + Text(title) .foregroundStyle(Theme.Colors.textPrimary) .font(Theme.Fonts.titleMedium) Spacer() @@ -46,16 +87,14 @@ public struct DatesShiftedSuccessView: View { .tint(Theme.Colors.textPrimary) } - Text(CourseLocalization.CourseDates.toastSuccessMessage) + Text(message) .foregroundStyle(Theme.Colors.textPrimary) .font(Theme.Fonts.labelLarge) if selectedTab == .course { Button(CourseLocalization.CourseDates.viewAllDates, action: { - Task { - await action() - } + action() withAnimation { dismissView() } @@ -99,15 +138,20 @@ public struct DatesShiftedSuccessView: View { private func dismissView() { dismiss = true - courseDatesViewModel?.resetDueDatesShiftedFlag() + courseDatesViewModel?.resetEventState() courseContainerViewModel?.resetDueDatesShiftedFlag() + dismissAction() } } #if DEBUG -struct DatesShiftedSuccessView_Previews: PreviewProvider { +struct DatesSuccessView_Previews: PreviewProvider { static var previews: some View { - DatesShiftedSuccessView(selectedTab: .course) + DatesSuccessView( + title: CourseLocalization.CourseDates.toastSuccessTitle, + message: CourseLocalization.CourseDates.toastSuccessMessage, + selectedTab: .course + ) {} } } #endif diff --git a/Course/Course/en.lproj/Localizable.strings b/Course/Course/en.lproj/Localizable.strings index aede5dc86..52d82216c 100644 --- a/Course/Course/en.lproj/Localizable.strings +++ b/Course/Course/en.lproj/Localizable.strings @@ -83,6 +83,25 @@ "COURSE_DATES.TOAST_SUCCESS_TITLE" = "Due dates shifted"; "COURSE_DATES.TOAST_SUCCESS_MESSAGE" = "Your due dates have been successfully shifted to help you stay on track."; "COURSE_DATES.VIEW_ALL_DATES" = "View all dates"; +"COURSE_DATES.SYNC_TO_CALENDAR" = "Sync to calendar"; +"COURSE_DATES.SYNC_TO_CALENDAR_MESSAGE" = "Automatically sync all deadlines and due dates for this course to your calendar."; +"COURSE_DATES.ADD_CALENDAR_TITLE"="Add calendar"; +"COURSE_DATES.REMOVE_CALENDAR_TITLE"="Remove calendar"; +"COURSE_DATES.ADD_CALENDAR_PROMPT"="Would you like to add the %@ calendar \"%@\" ? \n You can edit or remove the course calendar any time in Calendar or Settings"; +"COURSE_DATES.REMOVE_CALENDAR_PROMPT"="Would you like to remove the %@ calendar \"%@\" ?"; +"COURSE_DATES.DATES_ADDED_ALERT_MESSAGE" = "\"%@\" has been added to your calendar."; +"COURSE_DATES.CALENDAR_SYNC_MESSAGE"="Syncing calendar..."; +"COURSE_DATES.CALENDAR_VIEW_EVENTS"="View Events"; +"COURSE_DATES.CALENDAR_EVENTS_ADDED"="Your course calendar has been added."; +"COURSE_DATES.CALENDAR_EVENTS_REMOVED"="Your course calendar has been removed."; +"COURSE_DATES.CALENDAR_EVENTS"="Calendar events"; +"COURSE_DATES.CALENDAR_OUT_OF_DATE"="Your course calendar is out of date"; +"COURSE_DATES.CALENDAR_SHIFT_MESSAGE"="Your course dates have been shifted and your course calendar is no longer up to date with your new schedule."; +"COURSE_DATES.CALENDAR_SHIFT_PROMPT_UPDATE_NOW"="Update now"; +"COURSE_DATES.CALENDAR_EVENTS_UPDATED"="Your course calendar has been updated."; +"COURSE_DATES.CALENDAR_PERMISSION_NOT_DETERMINED"="%@ does not have calendar permission. Please go to settings and give calender permission."; +"COURSE_DATES.OPEN_SETTINGS"="Open Settings"; +"COURSE_DATES.SETTINGS" = "Settings"; "COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BODY" = "Don't worry - shift our suggested schedule to complete past due assignments without losing any progress."; "COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BUTTON" = "Shift due dates"; diff --git a/Course/Course/uk.lproj/Localizable.strings b/Course/Course/uk.lproj/Localizable.strings index 746d539b4..38de538b8 100644 --- a/Course/Course/uk.lproj/Localizable.strings +++ b/Course/Course/uk.lproj/Localizable.strings @@ -82,6 +82,25 @@ "COURSE_DATES.TOAST_SUCCESS_TITLE" = "Due dates shifted"; "COURSE_DATES.TOAST_SUCCESS_MESSAGE" = "Your due dates have been successfully shifted to help you stay on track."; "COURSE_DATES.VIEW_ALL_DATES" = "View all dates"; +"COURSE_DATES.SYNC_TO_CALENDAR" = "Sync to calendar"; +"COURSE_DATES.SYNC_TO_CALENDAR_MESSAGE" = "Automatically sync all deadlines and due dates for this course to your calendar."; +"COURSE_DATES.ADD_CALENDAR_TITLE"="Add calendar"; +"COURSE_DATES.REMOVE_CALENDAR_TITLE"="Remove calendar"; +"COURSE_DATES.ADD_CALENDAR_PROMPT"="Would you like to add the %@ calendar \"%@\" ? \n You can edit or remove the course calendar any time in Calendar or Settings"; +"COURSE_DATES.REMOVE_CALENDAR_PROMPT"="Would you like to remove the %@ calendar \"%@\" ?"; +"COURSE_DATES.DATES_ADDED_ALERT_MESSAGE" = "\"%@\" has been added to your calendar."; +"COURSE_DATES.CALENDAR_SYNC_MESSAGE"="Syncing calendar..."; +"COURSE_DATES.CALENDAR_VIEW_EVENTS"="View Events"; +"COURSE_DATES.CALENDAR_EVENTS_ADDED"="Your course calendar has been added."; +"COURSE_DATES.CALENDAR_EVENTS_REMOVED"="Your course calendar has been removed."; +"COURSE_DATES.CALENDAR_EVENTS"="Calendar events"; +"COURSE_DATES.CALENDAR_OUT_OF_DATE"="Your course calendar is out of date"; +"COURSE_DATES.CALENDAR_SHIFT_MESSAGE"="Your course dates have been shifted and your course calendar is no longer up to date with your new schedule."; +"COURSE_DATES.CALENDAR_SHIFT_PROMPT_UPDATE_NOW"="Update now"; +"COURSE_DATES.CALENDAR_EVENTS_UPDATED"="Your course calendar has been updated."; +"COURSE_DATES.CALENDAR_PERMISSION_NOT_DETERMINED"="%@ does not have calendar permission. Please go to settings and give calender permission."; +"COURSE_DATES.OPEN_SETTINGS"="Open Settings"; +"COURSE_DATES.SETTINGS" = "Settings"; "COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BODY" = "Don't worry - shift our suggested schedule to complete past due assignments without losing any progress."; "COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BUTTON" = "Shift due dates"; diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index bd3e8f4a8..080b636d1 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -593,10 +593,10 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`) } - open func presentView(transitionStyle: UIModalTransitionStyle, view: any View) { - addInvocation(.m_presentView__transitionStyle_transitionStyleview_view(Parameter.value(`transitionStyle`), Parameter.value(`view`))) - let perform = methodPerformValue(.m_presentView__transitionStyle_transitionStyleview_view(Parameter.value(`transitionStyle`), Parameter.value(`view`))) as? (UIModalTransitionStyle, any View) -> Void - perform?(`transitionStyle`, `view`) + open func presentView(transitionStyle: UIModalTransitionStyle, view: any View, completion: (() -> Void)?) { + addInvocation(.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter.value(`transitionStyle`), Parameter.value(`view`), Parameter<(() -> Void)?>.value(`completion`))) + let perform = methodPerformValue(.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter.value(`transitionStyle`), Parameter.value(`view`), Parameter<(() -> Void)?>.value(`completion`))) as? (UIModalTransitionStyle, any View, (() -> Void)?) -> Void + perform?(`transitionStyle`, `view`, `completion`) } open func presentView(transitionStyle: UIModalTransitionStyle, animated: Bool, content: () -> any View) { @@ -621,7 +621,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) - case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) + case m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) case m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(Parameter, Parameter, Parameter<() -> any View>) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -701,10 +701,11 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectiontapped, rhs: rhsNextsectiontapped, with: matcher), lhsNextsectiontapped, rhsNextsectiontapped, "nextSectionTapped")) return Matcher.ComparisonResult(results) - case (.m_presentView__transitionStyle_transitionStyleview_view(let lhsTransitionstyle, let lhsView), .m_presentView__transitionStyle_transitionStyleview_view(let rhsTransitionstyle, let rhsView)): + case (.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(let lhsTransitionstyle, let lhsView, let lhsCompletion), .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(let rhsTransitionstyle, let rhsView, let rhsCompletion)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTransitionstyle, rhs: rhsTransitionstyle, with: matcher), lhsTransitionstyle, rhsTransitionstyle, "transitionStyle")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsView, rhs: rhsView, with: matcher), lhsView, rhsView, "view")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCompletion, rhs: rhsCompletion, with: matcher), lhsCompletion, rhsCompletion, "completion")) return Matcher.ComparisonResult(results) case (.m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(let lhsTransitionstyle, let lhsAnimated, let lhsContent), .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(let rhsTransitionstyle, let rhsAnimated, let rhsContent)): @@ -733,7 +734,7 @@ open class BaseRouterMock: BaseRouter, Mock { case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue - case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue + case let .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case let .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue } } @@ -753,7 +754,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" - case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" + case .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" case .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content: return ".presentView(transitionStyle:animated:content:)" } } @@ -787,7 +788,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showWebBrowser(title: Parameter, url: Parameter) -> Verify { return Verify(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} - public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} + public static func presentView(transitionStyle: Parameter, view: Parameter, completion: Parameter<(() -> Void)?>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`))} public static func presentView(transitionStyle: Parameter, animated: Parameter, content: Parameter<() -> any View>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(`transitionStyle`, `animated`, `content`))} } @@ -837,8 +838,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>, perform: @escaping (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`), performs: perform) } - public static func presentView(transitionStyle: Parameter, view: Parameter, perform: @escaping (UIModalTransitionStyle, any View) -> Void) -> Perform { - return Perform(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`), performs: perform) + public static func presentView(transitionStyle: Parameter, view: Parameter, completion: Parameter<(() -> Void)?>, perform: @escaping (UIModalTransitionStyle, any View, (() -> Void)?) -> Void) -> Perform { + return Perform(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`), performs: perform) } public static func presentView(transitionStyle: Parameter, animated: Parameter, content: Parameter<() -> any View>, perform: @escaping (UIModalTransitionStyle, Bool, () -> any View) -> Void) -> Perform { return Perform(method: .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(`transitionStyle`, `animated`, `content`), performs: perform) diff --git a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift index d8160aff6..d86174a66 100644 --- a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift @@ -53,6 +53,7 @@ final class CourseContainerViewModelTests: XCTestCase { type: .problem, displayName: "", studentUrl: "", + webUrl: "", encodedVideo: nil, multiDevice: true ) @@ -362,6 +363,7 @@ final class CourseContainerViewModelTests: XCTestCase { type: .video, displayName: "", studentUrl: "", + webUrl: "", encodedVideo: .init( fallback: nil, youtube: nil, @@ -496,6 +498,7 @@ final class CourseContainerViewModelTests: XCTestCase { type: .video, displayName: "", studentUrl: "", + webUrl: "", encodedVideo: .init( fallback: nil, youtube: nil, @@ -614,6 +617,7 @@ final class CourseContainerViewModelTests: XCTestCase { type: .video, displayName: "", studentUrl: "", + webUrl: "", encodedVideo: .init( fallback: nil, youtube: nil, @@ -733,6 +737,7 @@ final class CourseContainerViewModelTests: XCTestCase { type: .video, displayName: "", studentUrl: "", + webUrl: "", encodedVideo: .init( fallback: nil, youtube: nil, @@ -845,6 +850,7 @@ final class CourseContainerViewModelTests: XCTestCase { type: .video, displayName: "", studentUrl: "", + webUrl: "", encodedVideo: .init( fallback: nil, youtube: nil, @@ -972,6 +978,7 @@ final class CourseContainerViewModelTests: XCTestCase { type: .video, displayName: "", studentUrl: "", + webUrl: "", encodedVideo: .init( fallback: nil, youtube: nil, @@ -1098,6 +1105,7 @@ final class CourseContainerViewModelTests: XCTestCase { type: .video, displayName: "", studentUrl: "", + webUrl: "", encodedVideo: .init( fallback: nil, youtube: nil, @@ -1118,6 +1126,7 @@ final class CourseContainerViewModelTests: XCTestCase { type: .video, displayName: "", studentUrl: "", + webUrl: "", encodedVideo: .init( fallback: nil, youtube: nil, diff --git a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift index 68f23f71b..5e10ac799 100644 --- a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift @@ -17,6 +17,7 @@ final class CourseDateViewModelTests: XCTestCase { let router = CourseRouterMock() let cssInjector = CSSInjectorMock() let connectivity = ConnectivityProtocolMock() + let config = ConfigMock() let courseDates = CourseDates( datesBannerInfo: @@ -31,15 +32,34 @@ final class CourseDateViewModelTests: XCTestCase { learnerIsFullAccess: false, userTimezone: nil) + let courseStructure = CourseStructure( + id: "123", + graded: true, + completion: 0, + viewYouTubeUrl: "", + encodedVideo: "", + displayName: "", + topicID: nil, + childs: [], + media: DataLayer.CourseMedia(image: DataLayer.Image(raw: "", + small: "", + large: "")), + certificate: nil + ) + Given(interactor, .getCourseDates(courseID: .any, willReturn: courseDates)) + Given(interactor, .getLoadedCourseBlocks(courseID: .any, willReturn: courseStructure)) let viewModel = CourseDatesViewModel( interactor: interactor, router: router, cssInjector: cssInjector, connectivity: connectivity, + config: config, courseID: "1", - analytics: CourseAnalyticsMock()) + courseName: "a", + analytics: CourseAnalyticsMock() + ) await viewModel.getCourseDates(courseID: "1") @@ -56,6 +76,7 @@ final class CourseDateViewModelTests: XCTestCase { let router = CourseRouterMock() let cssInjector = CSSInjectorMock() let connectivity = ConnectivityProtocolMock() + let config = ConfigMock() Given(interactor, .getCourseDates(courseID: .any, willThrow: NSError())) @@ -64,8 +85,11 @@ final class CourseDateViewModelTests: XCTestCase { router: router, cssInjector: cssInjector, connectivity: connectivity, + config: config, courseID: "1", - analytics: CourseAnalyticsMock()) + courseName: "a", + analytics: CourseAnalyticsMock() + ) await viewModel.getCourseDates(courseID: "1") @@ -80,6 +104,7 @@ final class CourseDateViewModelTests: XCTestCase { let router = CourseRouterMock() let cssInjector = CSSInjectorMock() let connectivity = ConnectivityProtocolMock() + let config = ConfigMock() let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -90,8 +115,11 @@ final class CourseDateViewModelTests: XCTestCase { router: router, cssInjector: cssInjector, connectivity: connectivity, + config: config, courseID: "1", - analytics: CourseAnalyticsMock()) + courseName: "a", + analytics: CourseAnalyticsMock() + ) await viewModel.getCourseDates(courseID: "1") diff --git a/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift index d953996eb..1e206e81e 100644 --- a/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift @@ -25,6 +25,7 @@ final class CourseUnitViewModelTests: XCTestCase { type: .video, displayName: "Lesson 1", studentUrl: "", + webUrl: "", encodedVideo: nil, multiDevice: true ), @@ -37,6 +38,7 @@ final class CourseUnitViewModelTests: XCTestCase { type: .video, displayName: "Lesson 2", studentUrl: "2", + webUrl: "2", encodedVideo: nil, multiDevice: false ), @@ -49,6 +51,7 @@ final class CourseUnitViewModelTests: XCTestCase { type: .unknown, displayName: "Lesson 3", studentUrl: "3", + webUrl: "3", encodedVideo: nil, multiDevice: true ), @@ -61,6 +64,7 @@ final class CourseUnitViewModelTests: XCTestCase { type: .unknown, displayName: "4", studentUrl: "4", + webUrl: "4", encodedVideo: nil, multiDevice: false ), diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index 8f1b9f79e..fb6a1334e 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -593,10 +593,10 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`) } - open func presentView(transitionStyle: UIModalTransitionStyle, view: any View) { - addInvocation(.m_presentView__transitionStyle_transitionStyleview_view(Parameter.value(`transitionStyle`), Parameter.value(`view`))) - let perform = methodPerformValue(.m_presentView__transitionStyle_transitionStyleview_view(Parameter.value(`transitionStyle`), Parameter.value(`view`))) as? (UIModalTransitionStyle, any View) -> Void - perform?(`transitionStyle`, `view`) + open func presentView(transitionStyle: UIModalTransitionStyle, view: any View, completion: (() -> Void)?) { + addInvocation(.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter.value(`transitionStyle`), Parameter.value(`view`), Parameter<(() -> Void)?>.value(`completion`))) + let perform = methodPerformValue(.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter.value(`transitionStyle`), Parameter.value(`view`), Parameter<(() -> Void)?>.value(`completion`))) as? (UIModalTransitionStyle, any View, (() -> Void)?) -> Void + perform?(`transitionStyle`, `view`, `completion`) } open func presentView(transitionStyle: UIModalTransitionStyle, animated: Bool, content: () -> any View) { @@ -621,7 +621,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) - case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) + case m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) case m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(Parameter, Parameter, Parameter<() -> any View>) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -701,10 +701,11 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectiontapped, rhs: rhsNextsectiontapped, with: matcher), lhsNextsectiontapped, rhsNextsectiontapped, "nextSectionTapped")) return Matcher.ComparisonResult(results) - case (.m_presentView__transitionStyle_transitionStyleview_view(let lhsTransitionstyle, let lhsView), .m_presentView__transitionStyle_transitionStyleview_view(let rhsTransitionstyle, let rhsView)): + case (.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(let lhsTransitionstyle, let lhsView, let lhsCompletion), .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(let rhsTransitionstyle, let rhsView, let rhsCompletion)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTransitionstyle, rhs: rhsTransitionstyle, with: matcher), lhsTransitionstyle, rhsTransitionstyle, "transitionStyle")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsView, rhs: rhsView, with: matcher), lhsView, rhsView, "view")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCompletion, rhs: rhsCompletion, with: matcher), lhsCompletion, rhsCompletion, "completion")) return Matcher.ComparisonResult(results) case (.m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(let lhsTransitionstyle, let lhsAnimated, let lhsContent), .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(let rhsTransitionstyle, let rhsAnimated, let rhsContent)): @@ -733,7 +734,7 @@ open class BaseRouterMock: BaseRouter, Mock { case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue - case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue + case let .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case let .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue } } @@ -753,7 +754,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" - case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" + case .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" case .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content: return ".presentView(transitionStyle:animated:content:)" } } @@ -787,7 +788,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showWebBrowser(title: Parameter, url: Parameter) -> Verify { return Verify(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} - public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} + public static func presentView(transitionStyle: Parameter, view: Parameter, completion: Parameter<(() -> Void)?>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`))} public static func presentView(transitionStyle: Parameter, animated: Parameter, content: Parameter<() -> any View>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(`transitionStyle`, `animated`, `content`))} } @@ -837,8 +838,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>, perform: @escaping (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`), performs: perform) } - public static func presentView(transitionStyle: Parameter, view: Parameter, perform: @escaping (UIModalTransitionStyle, any View) -> Void) -> Perform { - return Perform(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`), performs: perform) + public static func presentView(transitionStyle: Parameter, view: Parameter, completion: Parameter<(() -> Void)?>, perform: @escaping (UIModalTransitionStyle, any View, (() -> Void)?) -> Void) -> Perform { + return Perform(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`), performs: perform) } public static func presentView(transitionStyle: Parameter, animated: Parameter, content: Parameter<() -> any View>, perform: @escaping (UIModalTransitionStyle, Bool, () -> any View) -> Void) -> Perform { return Perform(method: .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(`transitionStyle`, `animated`, `content`), performs: perform) diff --git a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index 2721eded5..28f8eba5a 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -593,10 +593,10 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`) } - open func presentView(transitionStyle: UIModalTransitionStyle, view: any View) { - addInvocation(.m_presentView__transitionStyle_transitionStyleview_view(Parameter.value(`transitionStyle`), Parameter.value(`view`))) - let perform = methodPerformValue(.m_presentView__transitionStyle_transitionStyleview_view(Parameter.value(`transitionStyle`), Parameter.value(`view`))) as? (UIModalTransitionStyle, any View) -> Void - perform?(`transitionStyle`, `view`) + open func presentView(transitionStyle: UIModalTransitionStyle, view: any View, completion: (() -> Void)?) { + addInvocation(.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter.value(`transitionStyle`), Parameter.value(`view`), Parameter<(() -> Void)?>.value(`completion`))) + let perform = methodPerformValue(.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter.value(`transitionStyle`), Parameter.value(`view`), Parameter<(() -> Void)?>.value(`completion`))) as? (UIModalTransitionStyle, any View, (() -> Void)?) -> Void + perform?(`transitionStyle`, `view`, `completion`) } open func presentView(transitionStyle: UIModalTransitionStyle, animated: Bool, content: () -> any View) { @@ -621,7 +621,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) - case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) + case m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) case m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(Parameter, Parameter, Parameter<() -> any View>) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -701,10 +701,11 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectiontapped, rhs: rhsNextsectiontapped, with: matcher), lhsNextsectiontapped, rhsNextsectiontapped, "nextSectionTapped")) return Matcher.ComparisonResult(results) - case (.m_presentView__transitionStyle_transitionStyleview_view(let lhsTransitionstyle, let lhsView), .m_presentView__transitionStyle_transitionStyleview_view(let rhsTransitionstyle, let rhsView)): + case (.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(let lhsTransitionstyle, let lhsView, let lhsCompletion), .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(let rhsTransitionstyle, let rhsView, let rhsCompletion)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTransitionstyle, rhs: rhsTransitionstyle, with: matcher), lhsTransitionstyle, rhsTransitionstyle, "transitionStyle")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsView, rhs: rhsView, with: matcher), lhsView, rhsView, "view")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCompletion, rhs: rhsCompletion, with: matcher), lhsCompletion, rhsCompletion, "completion")) return Matcher.ComparisonResult(results) case (.m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(let lhsTransitionstyle, let lhsAnimated, let lhsContent), .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(let rhsTransitionstyle, let rhsAnimated, let rhsContent)): @@ -733,7 +734,7 @@ open class BaseRouterMock: BaseRouter, Mock { case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue - case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue + case let .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case let .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue } } @@ -753,7 +754,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" - case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" + case .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" case .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content: return ".presentView(transitionStyle:animated:content:)" } } @@ -787,7 +788,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showWebBrowser(title: Parameter, url: Parameter) -> Verify { return Verify(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} - public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} + public static func presentView(transitionStyle: Parameter, view: Parameter, completion: Parameter<(() -> Void)?>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`))} public static func presentView(transitionStyle: Parameter, animated: Parameter, content: Parameter<() -> any View>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(`transitionStyle`, `animated`, `content`))} } @@ -837,8 +838,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>, perform: @escaping (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`), performs: perform) } - public static func presentView(transitionStyle: Parameter, view: Parameter, perform: @escaping (UIModalTransitionStyle, any View) -> Void) -> Perform { - return Perform(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`), performs: perform) + public static func presentView(transitionStyle: Parameter, view: Parameter, completion: Parameter<(() -> Void)?>, perform: @escaping (UIModalTransitionStyle, any View, (() -> Void)?) -> Void) -> Perform { + return Perform(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`), performs: perform) } public static func presentView(transitionStyle: Parameter, animated: Parameter, content: Parameter<() -> any View>, perform: @escaping (UIModalTransitionStyle, Bool, () -> any View) -> Void) -> Perform { return Perform(method: .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(`transitionStyle`, `animated`, `content`), performs: perform) diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index 4da34aadc..5fb0292dd 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -593,10 +593,10 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`) } - open func presentView(transitionStyle: UIModalTransitionStyle, view: any View) { - addInvocation(.m_presentView__transitionStyle_transitionStyleview_view(Parameter.value(`transitionStyle`), Parameter.value(`view`))) - let perform = methodPerformValue(.m_presentView__transitionStyle_transitionStyleview_view(Parameter.value(`transitionStyle`), Parameter.value(`view`))) as? (UIModalTransitionStyle, any View) -> Void - perform?(`transitionStyle`, `view`) + open func presentView(transitionStyle: UIModalTransitionStyle, view: any View, completion: (() -> Void)?) { + addInvocation(.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter.value(`transitionStyle`), Parameter.value(`view`), Parameter<(() -> Void)?>.value(`completion`))) + let perform = methodPerformValue(.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter.value(`transitionStyle`), Parameter.value(`view`), Parameter<(() -> Void)?>.value(`completion`))) as? (UIModalTransitionStyle, any View, (() -> Void)?) -> Void + perform?(`transitionStyle`, `view`, `completion`) } open func presentView(transitionStyle: UIModalTransitionStyle, animated: Bool, content: () -> any View) { @@ -621,7 +621,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) - case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) + case m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) case m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(Parameter, Parameter, Parameter<() -> any View>) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -701,10 +701,11 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectiontapped, rhs: rhsNextsectiontapped, with: matcher), lhsNextsectiontapped, rhsNextsectiontapped, "nextSectionTapped")) return Matcher.ComparisonResult(results) - case (.m_presentView__transitionStyle_transitionStyleview_view(let lhsTransitionstyle, let lhsView), .m_presentView__transitionStyle_transitionStyleview_view(let rhsTransitionstyle, let rhsView)): + case (.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(let lhsTransitionstyle, let lhsView, let lhsCompletion), .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(let rhsTransitionstyle, let rhsView, let rhsCompletion)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTransitionstyle, rhs: rhsTransitionstyle, with: matcher), lhsTransitionstyle, rhsTransitionstyle, "transitionStyle")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsView, rhs: rhsView, with: matcher), lhsView, rhsView, "view")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCompletion, rhs: rhsCompletion, with: matcher), lhsCompletion, rhsCompletion, "completion")) return Matcher.ComparisonResult(results) case (.m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(let lhsTransitionstyle, let lhsAnimated, let lhsContent), .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(let rhsTransitionstyle, let rhsAnimated, let rhsContent)): @@ -733,7 +734,7 @@ open class BaseRouterMock: BaseRouter, Mock { case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue - case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue + case let .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case let .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue } } @@ -753,7 +754,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" - case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" + case .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" case .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content: return ".presentView(transitionStyle:animated:content:)" } } @@ -787,7 +788,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showWebBrowser(title: Parameter, url: Parameter) -> Verify { return Verify(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} - public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} + public static func presentView(transitionStyle: Parameter, view: Parameter, completion: Parameter<(() -> Void)?>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`))} public static func presentView(transitionStyle: Parameter, animated: Parameter, content: Parameter<() -> any View>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(`transitionStyle`, `animated`, `content`))} } @@ -837,8 +838,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>, perform: @escaping (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`), performs: perform) } - public static func presentView(transitionStyle: Parameter, view: Parameter, perform: @escaping (UIModalTransitionStyle, any View) -> Void) -> Perform { - return Perform(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`), performs: perform) + public static func presentView(transitionStyle: Parameter, view: Parameter, completion: Parameter<(() -> Void)?>, perform: @escaping (UIModalTransitionStyle, any View, (() -> Void)?) -> Void) -> Perform { + return Perform(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`), performs: perform) } public static func presentView(transitionStyle: Parameter, animated: Parameter, content: Parameter<() -> any View>, perform: @escaping (UIModalTransitionStyle, Bool, () -> any View) -> Void) -> Perform { return Perform(method: .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(`transitionStyle`, `animated`, `content`), performs: perform) @@ -2598,10 +2599,10 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { perform?(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`) } - open func presentView(transitionStyle: UIModalTransitionStyle, view: any View) { - addInvocation(.m_presentView__transitionStyle_transitionStyleview_view(Parameter.value(`transitionStyle`), Parameter.value(`view`))) - let perform = methodPerformValue(.m_presentView__transitionStyle_transitionStyleview_view(Parameter.value(`transitionStyle`), Parameter.value(`view`))) as? (UIModalTransitionStyle, any View) -> Void - perform?(`transitionStyle`, `view`) + open func presentView(transitionStyle: UIModalTransitionStyle, view: any View, completion: (() -> Void)?) { + addInvocation(.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter.value(`transitionStyle`), Parameter.value(`view`), Parameter<(() -> Void)?>.value(`completion`))) + let perform = methodPerformValue(.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter.value(`transitionStyle`), Parameter.value(`view`), Parameter<(() -> Void)?>.value(`completion`))) as? (UIModalTransitionStyle, any View, (() -> Void)?) -> Void + perform?(`transitionStyle`, `view`, `completion`) } open func presentView(transitionStyle: UIModalTransitionStyle, animated: Bool, content: () -> any View) { @@ -2632,7 +2633,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) - case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) + case m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) case m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(Parameter, Parameter, Parameter<() -> any View>) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -2753,10 +2754,11 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectiontapped, rhs: rhsNextsectiontapped, with: matcher), lhsNextsectiontapped, rhsNextsectiontapped, "nextSectionTapped")) return Matcher.ComparisonResult(results) - case (.m_presentView__transitionStyle_transitionStyleview_view(let lhsTransitionstyle, let lhsView), .m_presentView__transitionStyle_transitionStyleview_view(let rhsTransitionstyle, let rhsView)): + case (.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(let lhsTransitionstyle, let lhsView, let lhsCompletion), .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(let rhsTransitionstyle, let rhsView, let rhsCompletion)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTransitionstyle, rhs: rhsTransitionstyle, with: matcher), lhsTransitionstyle, rhsTransitionstyle, "transitionStyle")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsView, rhs: rhsView, with: matcher), lhsView, rhsView, "view")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCompletion, rhs: rhsCompletion, with: matcher), lhsCompletion, rhsCompletion, "completion")) return Matcher.ComparisonResult(results) case (.m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(let lhsTransitionstyle, let lhsAnimated, let lhsContent), .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(let rhsTransitionstyle, let rhsAnimated, let rhsContent)): @@ -2791,7 +2793,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue - case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue + case let .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case let .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue } } @@ -2817,7 +2819,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" - case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" + case .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" case .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content: return ".presentView(transitionStyle:animated:content:)" } } @@ -2857,7 +2859,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { public static func showWebBrowser(title: Parameter, url: Parameter) -> Verify { return Verify(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} - public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} + public static func presentView(transitionStyle: Parameter, view: Parameter, completion: Parameter<(() -> Void)?>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`))} public static func presentView(transitionStyle: Parameter, animated: Parameter, content: Parameter<() -> any View>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(`transitionStyle`, `animated`, `content`))} } @@ -2925,8 +2927,8 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>, perform: @escaping (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`), performs: perform) } - public static func presentView(transitionStyle: Parameter, view: Parameter, perform: @escaping (UIModalTransitionStyle, any View) -> Void) -> Perform { - return Perform(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`), performs: perform) + public static func presentView(transitionStyle: Parameter, view: Parameter, completion: Parameter<(() -> Void)?>, perform: @escaping (UIModalTransitionStyle, any View, (() -> Void)?) -> Void) -> Perform { + return Perform(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`), performs: perform) } public static func presentView(transitionStyle: Parameter, animated: Parameter, content: Parameter<() -> any View>, perform: @escaping (UIModalTransitionStyle, Bool, () -> any View) -> Void) -> Perform { return Perform(method: .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(`transitionStyle`, `animated`, `content`), performs: perform) diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index cedc3842e..4dbeba697 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -355,13 +355,15 @@ class ScreenAssembly: Assembly { ) } - container.register(CourseDatesViewModel.self) { r, courseID in + container.register(CourseDatesViewModel.self) { r, courseID, courseName in CourseDatesViewModel( interactor: r.resolve(CourseInteractorProtocol.self)!, router: r.resolve(CourseRouter.self)!, cssInjector: r.resolve(CSSInjector.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, + config: r.resolve(ConfigProtocol.self)!, courseID: courseID, + courseName: courseName, analytics: r.resolve(CourseAnalytics.self)! ) } diff --git a/OpenEdX/Data/CoursePersistence.swift b/OpenEdX/Data/CoursePersistence.swift index 931d1e419..ff6eea345 100644 --- a/OpenEdX/Data/CoursePersistence.swift +++ b/OpenEdX/Data/CoursePersistence.swift @@ -113,6 +113,7 @@ public class CoursePersistence: CoursePersistenceProtocol { graded: $0.graded, completion: $0.completion, studentUrl: $0.studentUrl ?? "", + webUrl: $0.webUrl ?? "", type: $0.type ?? "", displayName: $0.displayName ?? "", descendants: $0.descendants, diff --git a/OpenEdX/Info.plist b/OpenEdX/Info.plist index dc623f961..9c038b978 100644 --- a/OpenEdX/Info.plist +++ b/OpenEdX/Info.plist @@ -31,5 +31,9 @@ UIViewControllerBasedStatusBarAppearance + NSCalendarsUsageDescription + We would like to utilize your calendar list to subscribe you to your personalized calendar for this course. + NSCalendarsFullAccessUsageDescription + We would like to utilize your calendar list to subscribe you to your personalized calendar for this course. diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index b15cccd80..c517a3cc6 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -196,8 +196,8 @@ public class Router: AuthorizationRouter, } } - public func presentView(transitionStyle: UIModalTransitionStyle, view: any View) { - present(transitionStyle: transitionStyle, view: view) + public func presentView(transitionStyle: UIModalTransitionStyle, view: any View, completion: (() -> Void)? = nil) { + present(transitionStyle: transitionStyle, view: view, completion: completion) } public func presentView(transitionStyle: UIModalTransitionStyle, animated: Bool, content: () -> any View) { @@ -411,31 +411,69 @@ public class Router: AuthorizationRouter, public func showCourseComponent( componentID: String, - courseStructure: CourseStructure) { + courseStructure: CourseStructure, + blockLink: String) { + var courseBlock: CourseBlock? + var courseName: String? + var chapterPosition: Int? + var sequentialPosition: Int? + var verticalPosition: Int? + courseStructure.childs.enumerated().forEach { chapterIndex, chapter in chapter.childs.enumerated().forEach { sequentialIndex, sequential in sequential.childs.enumerated().forEach { verticalIndex, vertical in vertical.childs.forEach { block in if block.id == componentID { - DispatchQueue.main.async { [weak self] in - guard let self else { return } - self.showCourseUnit( - courseName: courseStructure.displayName, - blockId: block.blockId, - courseID: courseStructure.id, - sectionName: sequential.displayName, - verticalIndex: verticalIndex, - chapters: courseStructure.childs, - chapterIndex: chapterIndex, - sequentialIndex: sequentialIndex) - } + courseBlock = block + courseName = sequential.displayName + chapterPosition = chapterIndex + sequentialPosition = sequentialIndex + verticalPosition = verticalIndex return } } } } } + + if let block = courseBlock { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.showCourseUnit( + courseName: courseStructure.displayName, + blockId: block.blockId, + courseID: courseStructure.id, + sectionName: courseName ?? "", + verticalIndex: verticalPosition ?? 0, + chapters: courseStructure.childs, + chapterIndex: chapterPosition ?? 0, + sequentialIndex: sequentialPosition ?? 0) + } + } else if !blockLink.isEmpty, let blockURL = URL(string: blockLink) { + DispatchQueue.main.async { [weak self] in + guard let self else { return } + self.openBlockInBrowser(blockURL: blockURL) + } + } } + + private func openBlockInBrowser(blockURL: URL) { + presentAlert( + alertTitle: "", + alertMessage: CoreLocalization.Courseware.courseContentNotAvailable, + positiveAction: CoreLocalization.openInBrowser, + onCloseTapped: { + self.dismiss(animated: true) + }, + okTapped: { + self.dismiss(animated: true) + if UIApplication.shared.canOpenURL(blockURL) { + UIApplication.shared.open(blockURL, options: [:], completionHandler: nil) + } + }, + type: .default(positiveAction: CoreLocalization.openInBrowser, image: nil) + ) + } public func showDownloads( downloads: [DownloadDataTask], @@ -622,11 +660,14 @@ public class Router: AuthorizationRouter, navigationController.pushViewController(controller, animated: true) } - private func present(transitionStyle: UIModalTransitionStyle, view: ToPresent) { + private func present( + transitionStyle: UIModalTransitionStyle, + view: ToPresent, + completion: (() -> Void)? = nil) { navigationController.present( prepareToPresent(view, transitionStyle: transitionStyle), animated: true, - completion: {} + completion: completion ) } diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index b0f2d231b..30ec58bb5 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -593,10 +593,10 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`) } - open func presentView(transitionStyle: UIModalTransitionStyle, view: any View) { - addInvocation(.m_presentView__transitionStyle_transitionStyleview_view(Parameter.value(`transitionStyle`), Parameter.value(`view`))) - let perform = methodPerformValue(.m_presentView__transitionStyle_transitionStyleview_view(Parameter.value(`transitionStyle`), Parameter.value(`view`))) as? (UIModalTransitionStyle, any View) -> Void - perform?(`transitionStyle`, `view`) + open func presentView(transitionStyle: UIModalTransitionStyle, view: any View, completion: (() -> Void)?) { + addInvocation(.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter.value(`transitionStyle`), Parameter.value(`view`), Parameter<(() -> Void)?>.value(`completion`))) + let perform = methodPerformValue(.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter.value(`transitionStyle`), Parameter.value(`view`), Parameter<(() -> Void)?>.value(`completion`))) as? (UIModalTransitionStyle, any View, (() -> Void)?) -> Void + perform?(`transitionStyle`, `view`, `completion`) } open func presentView(transitionStyle: UIModalTransitionStyle, animated: Bool, content: () -> any View) { @@ -621,7 +621,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) - case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) + case m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) case m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(Parameter, Parameter, Parameter<() -> any View>) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -701,10 +701,11 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectiontapped, rhs: rhsNextsectiontapped, with: matcher), lhsNextsectiontapped, rhsNextsectiontapped, "nextSectionTapped")) return Matcher.ComparisonResult(results) - case (.m_presentView__transitionStyle_transitionStyleview_view(let lhsTransitionstyle, let lhsView), .m_presentView__transitionStyle_transitionStyleview_view(let rhsTransitionstyle, let rhsView)): + case (.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(let lhsTransitionstyle, let lhsView, let lhsCompletion), .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(let rhsTransitionstyle, let rhsView, let rhsCompletion)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTransitionstyle, rhs: rhsTransitionstyle, with: matcher), lhsTransitionstyle, rhsTransitionstyle, "transitionStyle")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsView, rhs: rhsView, with: matcher), lhsView, rhsView, "view")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCompletion, rhs: rhsCompletion, with: matcher), lhsCompletion, rhsCompletion, "completion")) return Matcher.ComparisonResult(results) case (.m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(let lhsTransitionstyle, let lhsAnimated, let lhsContent), .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(let rhsTransitionstyle, let rhsAnimated, let rhsContent)): @@ -733,7 +734,7 @@ open class BaseRouterMock: BaseRouter, Mock { case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue - case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue + case let .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case let .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue } } @@ -753,7 +754,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" - case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" + case .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" case .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content: return ".presentView(transitionStyle:animated:content:)" } } @@ -787,7 +788,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showWebBrowser(title: Parameter, url: Parameter) -> Verify { return Verify(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} - public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} + public static func presentView(transitionStyle: Parameter, view: Parameter, completion: Parameter<(() -> Void)?>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`))} public static func presentView(transitionStyle: Parameter, animated: Parameter, content: Parameter<() -> any View>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(`transitionStyle`, `animated`, `content`))} } @@ -837,8 +838,8 @@ open class BaseRouterMock: BaseRouter, Mock { public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>, perform: @escaping (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`), performs: perform) } - public static func presentView(transitionStyle: Parameter, view: Parameter, perform: @escaping (UIModalTransitionStyle, any View) -> Void) -> Perform { - return Perform(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`), performs: perform) + public static func presentView(transitionStyle: Parameter, view: Parameter, completion: Parameter<(() -> Void)?>, perform: @escaping (UIModalTransitionStyle, any View, (() -> Void)?) -> Void) -> Perform { + return Perform(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`), performs: perform) } public static func presentView(transitionStyle: Parameter, animated: Parameter, content: Parameter<() -> any View>, perform: @escaping (UIModalTransitionStyle, Bool, () -> any View) -> Void) -> Perform { return Perform(method: .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(`transitionStyle`, `animated`, `content`), performs: perform) @@ -3146,10 +3147,10 @@ open class ProfileRouterMock: ProfileRouter, Mock { perform?(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`) } - open func presentView(transitionStyle: UIModalTransitionStyle, view: any View) { - addInvocation(.m_presentView__transitionStyle_transitionStyleview_view(Parameter.value(`transitionStyle`), Parameter.value(`view`))) - let perform = methodPerformValue(.m_presentView__transitionStyle_transitionStyleview_view(Parameter.value(`transitionStyle`), Parameter.value(`view`))) as? (UIModalTransitionStyle, any View) -> Void - perform?(`transitionStyle`, `view`) + open func presentView(transitionStyle: UIModalTransitionStyle, view: any View, completion: (() -> Void)?) { + addInvocation(.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter.value(`transitionStyle`), Parameter.value(`view`), Parameter<(() -> Void)?>.value(`completion`))) + let perform = methodPerformValue(.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter.value(`transitionStyle`), Parameter.value(`view`), Parameter<(() -> Void)?>.value(`completion`))) as? (UIModalTransitionStyle, any View, (() -> Void)?) -> Void + perform?(`transitionStyle`, `view`, `completion`) } open func presentView(transitionStyle: UIModalTransitionStyle, animated: Bool, content: () -> any View) { @@ -3179,7 +3180,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter) case m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter<() -> Void>, Parameter<() -> Void>, Parameter<() -> Void>) - case m_presentView__transitionStyle_transitionStyleview_view(Parameter, Parameter) + case m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) case m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(Parameter, Parameter, Parameter<() -> any View>) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -3282,10 +3283,11 @@ open class ProfileRouterMock: ProfileRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectiontapped, rhs: rhsNextsectiontapped, with: matcher), lhsNextsectiontapped, rhsNextsectiontapped, "nextSectionTapped")) return Matcher.ComparisonResult(results) - case (.m_presentView__transitionStyle_transitionStyleview_view(let lhsTransitionstyle, let lhsView), .m_presentView__transitionStyle_transitionStyleview_view(let rhsTransitionstyle, let rhsView)): + case (.m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(let lhsTransitionstyle, let lhsView, let lhsCompletion), .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(let rhsTransitionstyle, let rhsView, let rhsCompletion)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTransitionstyle, rhs: rhsTransitionstyle, with: matcher), lhsTransitionstyle, rhsTransitionstyle, "transitionStyle")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsView, rhs: rhsView, with: matcher), lhsView, rhsView, "view")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCompletion, rhs: rhsCompletion, with: matcher), lhsCompletion, rhsCompletion, "completion")) return Matcher.ComparisonResult(results) case (.m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(let lhsTransitionstyle, let lhsAnimated, let lhsContent), .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(let rhsTransitionstyle, let rhsAnimated, let rhsContent)): @@ -3319,7 +3321,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue case let .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(p0, p1, p2, p3, p4, p5, p6, p7): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + p6.intValue + p7.intValue - case let .m_presentView__transitionStyle_transitionStyleview_view(p0, p1): return p0.intValue + p1.intValue + case let .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case let .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue } } @@ -3344,7 +3346,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type: return ".presentAlert(alertTitle:alertMessage:positiveAction:onCloseTapped:okTapped:type:)" case .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped: return ".presentAlert(alertTitle:alertMessage:nextSectionName:action:image:onCloseTapped:okTapped:nextSectionTapped:)" - case .m_presentView__transitionStyle_transitionStyleview_view: return ".presentView(transitionStyle:view:)" + case .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" case .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content: return ".presentView(transitionStyle:animated:content:)" } } @@ -3383,7 +3385,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { public static func showWebBrowser(title: Parameter, url: Parameter) -> Verify { return Verify(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`))} public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>) -> Verify { return Verify(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`))} - public static func presentView(transitionStyle: Parameter, view: Parameter) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`))} + public static func presentView(transitionStyle: Parameter, view: Parameter, completion: Parameter<(() -> Void)?>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`))} public static func presentView(transitionStyle: Parameter, animated: Parameter, content: Parameter<() -> any View>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(`transitionStyle`, `animated`, `content`))} } @@ -3448,8 +3450,8 @@ open class ProfileRouterMock: ProfileRouter, Mock { public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, nextSectionName: Parameter, action: Parameter, image: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, nextSectionTapped: Parameter<() -> Void>, perform: @escaping (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`), performs: perform) } - public static func presentView(transitionStyle: Parameter, view: Parameter, perform: @escaping (UIModalTransitionStyle, any View) -> Void) -> Perform { - return Perform(method: .m_presentView__transitionStyle_transitionStyleview_view(`transitionStyle`, `view`), performs: perform) + public static func presentView(transitionStyle: Parameter, view: Parameter, completion: Parameter<(() -> Void)?>, perform: @escaping (UIModalTransitionStyle, any View, (() -> Void)?) -> Void) -> Perform { + return Perform(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`), performs: perform) } public static func presentView(transitionStyle: Parameter, animated: Parameter, content: Parameter<() -> any View>, perform: @escaping (UIModalTransitionStyle, Bool, () -> any View) -> Void) -> Perform { return Perform(method: .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(`transitionStyle`, `animated`, `content`), performs: perform) From 69dfae6f7ba9b599fd7159ea99d296a7759b30d8 Mon Sep 17 00:00:00 2001 From: Eugene Yatsenko Date: Wed, 27 Mar 2024 10:59:49 +0100 Subject: [PATCH 093/136] chore: back add a post title --- Discussion/Discussion/Presentation/Posts/PostsView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index a145aa7cb..d668e3977 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -184,7 +184,7 @@ public struct PostsView: View { .frame(maxWidth: .infinity) .padding(.top, 12) StyledButton( - DiscussionLocalization.Posts.NoDiscussion.createbutton, + DiscussionLocalization.Posts.NoDiscussion.addPost, action: { router.createNewThread(courseID: courseID, selectedTopic: currentBlockID, From 40338f24aea330f2b4537f6218768ccbf90299f4 Mon Sep 17 00:00:00 2001 From: Eugene Yatsenko Date: Wed, 27 Mar 2024 12:33:10 +0100 Subject: [PATCH 094/136] chore: add tests --- .../DiscussionTopicsViewModel.swift | 2 +- .../DiscussionMock.generated.swift | 117 +++++++++++------- .../DiscussionTopicsViewModelTests.swift | 28 +++-- .../Posts/PostViewModelTests.swift | 14 ++- 4 files changed, 103 insertions(+), 58 deletions(-) diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift index 24c7d4f31..c627bfbe8 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift @@ -68,7 +68,7 @@ public class DiscussionTopicsViewModel: ObservableObject { style: .basic ), DiscussionTopic( - name: DiscussionLocalization.Topics.postImFollowing, + name: DiscussionLocalization.Topics.postImFollowing, action: { self.analytics.discussionFollowingClicked( courseId: self.courseID, diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index c060261a6..670484361 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -1649,10 +1649,20 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock - open func getCourseDiscussionInfo(courseID: String) { + open func getCourseDiscussionInfo(courseID: String) throws -> DiscussionInfo { addInvocation(.m_getCourseDiscussionInfo__courseID_courseID(Parameter.value(`courseID`))) let perform = methodPerformValue(.m_getCourseDiscussionInfo__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void perform?(`courseID`) + var __value: DiscussionInfo + do { + __value = try methodReturnValue(.m_getCourseDiscussionInfo__courseID_courseID(Parameter.value(`courseID`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for getCourseDiscussionInfo(courseID: String). Use given") + Failure("Stub return value not specified for getCourseDiscussionInfo(courseID: String). Use given") + } catch { + throw error + } + return __value } open func getThreadsList(courseID: String, type: ThreadType, sort: SortType, filter: ThreadsFilter, page: Int) throws -> ThreadLists { @@ -2094,6 +2104,9 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock } + public static func getCourseDiscussionInfo(courseID: Parameter, willReturn: DiscussionInfo...) -> MethodStub { + return Given(method: .m_getCourseDiscussionInfo__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func getThreadsList(courseID: Parameter, type: Parameter, sort: Parameter, filter: Parameter, page: Parameter, willReturn: ThreadLists...) -> MethodStub { return Given(method: .m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page(`courseID`, `type`, `sort`, `filter`, `page`), products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -2124,6 +2137,16 @@ open class DiscussionInteractorProtocolMock: DiscussionInteractorProtocol, Mock public static func addCommentTo(threadID: Parameter, rawBody: Parameter, parentID: Parameter, willReturn: Post...) -> MethodStub { return Given(method: .m_addCommentTo__threadID_threadIDrawBody_rawBodyparentID_parentID(`threadID`, `rawBody`, `parentID`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func getCourseDiscussionInfo(courseID: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_getCourseDiscussionInfo__courseID_courseID(`courseID`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getCourseDiscussionInfo(courseID: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_getCourseDiscussionInfo__courseID_courseID(`courseID`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (DiscussionInfo).self) + willProduce(stubber) + return given + } public static func getThreadsList(courseID: Parameter, type: Parameter, sort: Parameter, filter: Parameter, page: Parameter, willThrow: Error...) -> MethodStub { return Given(method: .m_getThreadsList__courseID_courseIDtype_typesort_sortfilter_filterpage_page(`courseID`, `type`, `sort`, `filter`, `page`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -2502,28 +2525,28 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { perform?(`username`) } - open func showThreads(courseID: String, topics: Topics, title: String, type: ThreadType, animated: Bool) { - addInvocation(.m_showThreads__courseID_courseIDtopics_topicstitle_titletype_typeanimated_animated(Parameter.value(`courseID`), Parameter.value(`topics`), Parameter.value(`title`), Parameter.value(`type`), Parameter.value(`animated`))) - let perform = methodPerformValue(.m_showThreads__courseID_courseIDtopics_topicstitle_titletype_typeanimated_animated(Parameter.value(`courseID`), Parameter.value(`topics`), Parameter.value(`title`), Parameter.value(`type`), Parameter.value(`animated`))) as? (String, Topics, String, ThreadType, Bool) -> Void - perform?(`courseID`, `topics`, `title`, `type`, `animated`) + open func showThreads(courseID: String, topics: Topics, title: String, type: ThreadType, isBlackedOut: Bool, animated: Bool) { + addInvocation(.m_showThreads__courseID_courseIDtopics_topicstitle_titletype_typeisBlackedOut_isBlackedOutanimated_animated(Parameter.value(`courseID`), Parameter.value(`topics`), Parameter.value(`title`), Parameter.value(`type`), Parameter.value(`isBlackedOut`), Parameter.value(`animated`))) + let perform = methodPerformValue(.m_showThreads__courseID_courseIDtopics_topicstitle_titletype_typeisBlackedOut_isBlackedOutanimated_animated(Parameter.value(`courseID`), Parameter.value(`topics`), Parameter.value(`title`), Parameter.value(`type`), Parameter.value(`isBlackedOut`), Parameter.value(`animated`))) as? (String, Topics, String, ThreadType, Bool, Bool) -> Void + perform?(`courseID`, `topics`, `title`, `type`, `isBlackedOut`, `animated`) } - open func showThread(thread: UserThread, postStateSubject: CurrentValueSubject, animated: Bool) { - addInvocation(.m_showThread__thread_threadpostStateSubject_postStateSubjectanimated_animated(Parameter.value(`thread`), Parameter>.value(`postStateSubject`), Parameter.value(`animated`))) - let perform = methodPerformValue(.m_showThread__thread_threadpostStateSubject_postStateSubjectanimated_animated(Parameter.value(`thread`), Parameter>.value(`postStateSubject`), Parameter.value(`animated`))) as? (UserThread, CurrentValueSubject, Bool) -> Void - perform?(`thread`, `postStateSubject`, `animated`) + open func showThread(thread: UserThread, postStateSubject: CurrentValueSubject, isBlackedOut: Bool, animated: Bool) { + addInvocation(.m_showThread__thread_threadpostStateSubject_postStateSubjectisBlackedOut_isBlackedOutanimated_animated(Parameter.value(`thread`), Parameter>.value(`postStateSubject`), Parameter.value(`isBlackedOut`), Parameter.value(`animated`))) + let perform = methodPerformValue(.m_showThread__thread_threadpostStateSubject_postStateSubjectisBlackedOut_isBlackedOutanimated_animated(Parameter.value(`thread`), Parameter>.value(`postStateSubject`), Parameter.value(`isBlackedOut`), Parameter.value(`animated`))) as? (UserThread, CurrentValueSubject, Bool, Bool) -> Void + perform?(`thread`, `postStateSubject`, `isBlackedOut`, `animated`) } - open func showDiscussionsSearch(courseID: String) { - addInvocation(.m_showDiscussionsSearch__courseID_courseID(Parameter.value(`courseID`))) - let perform = methodPerformValue(.m_showDiscussionsSearch__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void - perform?(`courseID`) + open func showDiscussionsSearch(courseID: String, isBlackedOut: Bool) { + addInvocation(.m_showDiscussionsSearch__courseID_courseIDisBlackedOut_isBlackedOut(Parameter.value(`courseID`), Parameter.value(`isBlackedOut`))) + let perform = methodPerformValue(.m_showDiscussionsSearch__courseID_courseIDisBlackedOut_isBlackedOut(Parameter.value(`courseID`), Parameter.value(`isBlackedOut`))) as? (String, Bool) -> Void + perform?(`courseID`, `isBlackedOut`) } - open func showComments(commentID: String, parentComment: Post, threadStateSubject: CurrentValueSubject, animated: Bool) { - addInvocation(.m_showComments__commentID_commentIDparentComment_parentCommentthreadStateSubject_threadStateSubjectanimated_animated(Parameter.value(`commentID`), Parameter.value(`parentComment`), Parameter>.value(`threadStateSubject`), Parameter.value(`animated`))) - let perform = methodPerformValue(.m_showComments__commentID_commentIDparentComment_parentCommentthreadStateSubject_threadStateSubjectanimated_animated(Parameter.value(`commentID`), Parameter.value(`parentComment`), Parameter>.value(`threadStateSubject`), Parameter.value(`animated`))) as? (String, Post, CurrentValueSubject, Bool) -> Void - perform?(`commentID`, `parentComment`, `threadStateSubject`, `animated`) + open func showComments(commentID: String, parentComment: Post, threadStateSubject: CurrentValueSubject, isBlackedOut: Bool, animated: Bool) { + addInvocation(.m_showComments__commentID_commentIDparentComment_parentCommentthreadStateSubject_threadStateSubjectisBlackedOut_isBlackedOutanimated_animated(Parameter.value(`commentID`), Parameter.value(`parentComment`), Parameter>.value(`threadStateSubject`), Parameter.value(`isBlackedOut`), Parameter.value(`animated`))) + let perform = methodPerformValue(.m_showComments__commentID_commentIDparentComment_parentCommentthreadStateSubject_threadStateSubjectisBlackedOut_isBlackedOutanimated_animated(Parameter.value(`commentID`), Parameter.value(`parentComment`), Parameter>.value(`threadStateSubject`), Parameter.value(`isBlackedOut`), Parameter.value(`animated`))) as? (String, Post, CurrentValueSubject, Bool, Bool) -> Void + perform?(`commentID`, `parentComment`, `threadStateSubject`, `isBlackedOut`, `animated`) } open func createNewThread(courseID: String, selectedTopic: String, onPostCreated: @escaping () -> Void) { @@ -2631,10 +2654,10 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { fileprivate enum MethodType { case m_showUserDetails__username_username(Parameter) - case m_showThreads__courseID_courseIDtopics_topicstitle_titletype_typeanimated_animated(Parameter, Parameter, Parameter, Parameter, Parameter) - case m_showThread__thread_threadpostStateSubject_postStateSubjectanimated_animated(Parameter, Parameter>, Parameter) - case m_showDiscussionsSearch__courseID_courseID(Parameter) - case m_showComments__commentID_commentIDparentComment_parentCommentthreadStateSubject_threadStateSubjectanimated_animated(Parameter, Parameter, Parameter>, Parameter) + case m_showThreads__courseID_courseIDtopics_topicstitle_titletype_typeisBlackedOut_isBlackedOutanimated_animated(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter) + case m_showThread__thread_threadpostStateSubject_postStateSubjectisBlackedOut_isBlackedOutanimated_animated(Parameter, Parameter>, Parameter, Parameter) + case m_showDiscussionsSearch__courseID_courseIDisBlackedOut_isBlackedOut(Parameter, Parameter) + case m_showComments__commentID_commentIDparentComment_parentCommentthreadStateSubject_threadStateSubjectisBlackedOut_isBlackedOutanimated_animated(Parameter, Parameter, Parameter>, Parameter, Parameter) case m_createNewThread__courseID_courseIDselectedTopic_selectedTopiconPostCreated_onPostCreated(Parameter, Parameter, Parameter<() -> Void>) case m_backToRoot__animated_animated(Parameter) case m_back__animated_animated(Parameter) @@ -2660,32 +2683,36 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUsername, rhs: rhsUsername, with: matcher), lhsUsername, rhsUsername, "username")) return Matcher.ComparisonResult(results) - case (.m_showThreads__courseID_courseIDtopics_topicstitle_titletype_typeanimated_animated(let lhsCourseid, let lhsTopics, let lhsTitle, let lhsType, let lhsAnimated), .m_showThreads__courseID_courseIDtopics_topicstitle_titletype_typeanimated_animated(let rhsCourseid, let rhsTopics, let rhsTitle, let rhsType, let rhsAnimated)): + case (.m_showThreads__courseID_courseIDtopics_topicstitle_titletype_typeisBlackedOut_isBlackedOutanimated_animated(let lhsCourseid, let lhsTopics, let lhsTitle, let lhsType, let lhsIsblackedout, let lhsAnimated), .m_showThreads__courseID_courseIDtopics_topicstitle_titletype_typeisBlackedOut_isBlackedOutanimated_animated(let rhsCourseid, let rhsTopics, let rhsTitle, let rhsType, let rhsIsblackedout, let rhsAnimated)): 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: lhsTopics, rhs: rhsTopics, with: matcher), lhsTopics, rhsTopics, "topics")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTitle, rhs: rhsTitle, with: matcher), lhsTitle, rhsTitle, "title")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsType, rhs: rhsType, with: matcher), lhsType, rhsType, "type")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsIsblackedout, rhs: rhsIsblackedout, with: matcher), lhsIsblackedout, rhsIsblackedout, "isBlackedOut")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAnimated, rhs: rhsAnimated, with: matcher), lhsAnimated, rhsAnimated, "animated")) return Matcher.ComparisonResult(results) - case (.m_showThread__thread_threadpostStateSubject_postStateSubjectanimated_animated(let lhsThread, let lhsPoststatesubject, let lhsAnimated), .m_showThread__thread_threadpostStateSubject_postStateSubjectanimated_animated(let rhsThread, let rhsPoststatesubject, let rhsAnimated)): + case (.m_showThread__thread_threadpostStateSubject_postStateSubjectisBlackedOut_isBlackedOutanimated_animated(let lhsThread, let lhsPoststatesubject, let lhsIsblackedout, let lhsAnimated), .m_showThread__thread_threadpostStateSubject_postStateSubjectisBlackedOut_isBlackedOutanimated_animated(let rhsThread, let rhsPoststatesubject, let rhsIsblackedout, let rhsAnimated)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsThread, rhs: rhsThread, with: matcher), lhsThread, rhsThread, "thread")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsPoststatesubject, rhs: rhsPoststatesubject, with: matcher), lhsPoststatesubject, rhsPoststatesubject, "postStateSubject")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsIsblackedout, rhs: rhsIsblackedout, with: matcher), lhsIsblackedout, rhsIsblackedout, "isBlackedOut")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAnimated, rhs: rhsAnimated, with: matcher), lhsAnimated, rhsAnimated, "animated")) return Matcher.ComparisonResult(results) - case (.m_showDiscussionsSearch__courseID_courseID(let lhsCourseid), .m_showDiscussionsSearch__courseID_courseID(let rhsCourseid)): + case (.m_showDiscussionsSearch__courseID_courseIDisBlackedOut_isBlackedOut(let lhsCourseid, let lhsIsblackedout), .m_showDiscussionsSearch__courseID_courseIDisBlackedOut_isBlackedOut(let rhsCourseid, let rhsIsblackedout)): 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: lhsIsblackedout, rhs: rhsIsblackedout, with: matcher), lhsIsblackedout, rhsIsblackedout, "isBlackedOut")) return Matcher.ComparisonResult(results) - case (.m_showComments__commentID_commentIDparentComment_parentCommentthreadStateSubject_threadStateSubjectanimated_animated(let lhsCommentid, let lhsParentcomment, let lhsThreadstatesubject, let lhsAnimated), .m_showComments__commentID_commentIDparentComment_parentCommentthreadStateSubject_threadStateSubjectanimated_animated(let rhsCommentid, let rhsParentcomment, let rhsThreadstatesubject, let rhsAnimated)): + case (.m_showComments__commentID_commentIDparentComment_parentCommentthreadStateSubject_threadStateSubjectisBlackedOut_isBlackedOutanimated_animated(let lhsCommentid, let lhsParentcomment, let lhsThreadstatesubject, let lhsIsblackedout, let lhsAnimated), .m_showComments__commentID_commentIDparentComment_parentCommentthreadStateSubject_threadStateSubjectisBlackedOut_isBlackedOutanimated_animated(let rhsCommentid, let rhsParentcomment, let rhsThreadstatesubject, let rhsIsblackedout, let rhsAnimated)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCommentid, rhs: rhsCommentid, with: matcher), lhsCommentid, rhsCommentid, "commentID")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParentcomment, rhs: rhsParentcomment, with: matcher), lhsParentcomment, rhsParentcomment, "parentComment")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsThreadstatesubject, rhs: rhsThreadstatesubject, with: matcher), lhsThreadstatesubject, rhsThreadstatesubject, "threadStateSubject")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsIsblackedout, rhs: rhsIsblackedout, with: matcher), lhsIsblackedout, rhsIsblackedout, "isBlackedOut")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAnimated, rhs: rhsAnimated, with: matcher), lhsAnimated, rhsAnimated, "animated")) return Matcher.ComparisonResult(results) @@ -2790,10 +2817,10 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { func intValue() -> Int { switch self { case let .m_showUserDetails__username_username(p0): return p0.intValue - case let .m_showThreads__courseID_courseIDtopics_topicstitle_titletype_typeanimated_animated(p0, p1, p2, p3, p4): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue - case let .m_showThread__thread_threadpostStateSubject_postStateSubjectanimated_animated(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue - case let .m_showDiscussionsSearch__courseID_courseID(p0): return p0.intValue - case let .m_showComments__commentID_commentIDparentComment_parentCommentthreadStateSubject_threadStateSubjectanimated_animated(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_showThreads__courseID_courseIDtopics_topicstitle_titletype_typeisBlackedOut_isBlackedOutanimated_animated(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue + case let .m_showThread__thread_threadpostStateSubject_postStateSubjectisBlackedOut_isBlackedOutanimated_animated(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_showDiscussionsSearch__courseID_courseIDisBlackedOut_isBlackedOut(p0, p1): return p0.intValue + p1.intValue + case let .m_showComments__commentID_commentIDparentComment_parentCommentthreadStateSubject_threadStateSubjectisBlackedOut_isBlackedOutanimated_animated(p0, p1, p2, p3, p4): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue case let .m_createNewThread__courseID_courseIDselectedTopic_selectedTopiconPostCreated_onPostCreated(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case let .m_backToRoot__animated_animated(p0): return p0.intValue case let .m_back__animated_animated(p0): return p0.intValue @@ -2816,10 +2843,10 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { func assertionName() -> String { switch self { case .m_showUserDetails__username_username: return ".showUserDetails(username:)" - case .m_showThreads__courseID_courseIDtopics_topicstitle_titletype_typeanimated_animated: return ".showThreads(courseID:topics:title:type:animated:)" - case .m_showThread__thread_threadpostStateSubject_postStateSubjectanimated_animated: return ".showThread(thread:postStateSubject:animated:)" - case .m_showDiscussionsSearch__courseID_courseID: return ".showDiscussionsSearch(courseID:)" - case .m_showComments__commentID_commentIDparentComment_parentCommentthreadStateSubject_threadStateSubjectanimated_animated: return ".showComments(commentID:parentComment:threadStateSubject:animated:)" + case .m_showThreads__courseID_courseIDtopics_topicstitle_titletype_typeisBlackedOut_isBlackedOutanimated_animated: return ".showThreads(courseID:topics:title:type:isBlackedOut:animated:)" + case .m_showThread__thread_threadpostStateSubject_postStateSubjectisBlackedOut_isBlackedOutanimated_animated: return ".showThread(thread:postStateSubject:isBlackedOut:animated:)" + case .m_showDiscussionsSearch__courseID_courseIDisBlackedOut_isBlackedOut: return ".showDiscussionsSearch(courseID:isBlackedOut:)" + case .m_showComments__commentID_commentIDparentComment_parentCommentthreadStateSubject_threadStateSubjectisBlackedOut_isBlackedOutanimated_animated: return ".showComments(commentID:parentComment:threadStateSubject:isBlackedOut:animated:)" case .m_createNewThread__courseID_courseIDselectedTopic_selectedTopiconPostCreated_onPostCreated: return ".createNewThread(courseID:selectedTopic:onPostCreated:)" case .m_backToRoot__animated_animated: return ".backToRoot(animated:)" case .m_back__animated_animated: return ".back(animated:)" @@ -2856,10 +2883,10 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { fileprivate var method: MethodType public static func showUserDetails(username: Parameter) -> Verify { return Verify(method: .m_showUserDetails__username_username(`username`))} - public static func showThreads(courseID: Parameter, topics: Parameter, title: Parameter, type: Parameter, animated: Parameter) -> Verify { return Verify(method: .m_showThreads__courseID_courseIDtopics_topicstitle_titletype_typeanimated_animated(`courseID`, `topics`, `title`, `type`, `animated`))} - public static func showThread(thread: Parameter, postStateSubject: Parameter>, animated: Parameter) -> Verify { return Verify(method: .m_showThread__thread_threadpostStateSubject_postStateSubjectanimated_animated(`thread`, `postStateSubject`, `animated`))} - public static func showDiscussionsSearch(courseID: Parameter) -> Verify { return Verify(method: .m_showDiscussionsSearch__courseID_courseID(`courseID`))} - public static func showComments(commentID: Parameter, parentComment: Parameter, threadStateSubject: Parameter>, animated: Parameter) -> Verify { return Verify(method: .m_showComments__commentID_commentIDparentComment_parentCommentthreadStateSubject_threadStateSubjectanimated_animated(`commentID`, `parentComment`, `threadStateSubject`, `animated`))} + public static func showThreads(courseID: Parameter, topics: Parameter, title: Parameter, type: Parameter, isBlackedOut: Parameter, animated: Parameter) -> Verify { return Verify(method: .m_showThreads__courseID_courseIDtopics_topicstitle_titletype_typeisBlackedOut_isBlackedOutanimated_animated(`courseID`, `topics`, `title`, `type`, `isBlackedOut`, `animated`))} + public static func showThread(thread: Parameter, postStateSubject: Parameter>, isBlackedOut: Parameter, animated: Parameter) -> Verify { return Verify(method: .m_showThread__thread_threadpostStateSubject_postStateSubjectisBlackedOut_isBlackedOutanimated_animated(`thread`, `postStateSubject`, `isBlackedOut`, `animated`))} + public static func showDiscussionsSearch(courseID: Parameter, isBlackedOut: Parameter) -> Verify { return Verify(method: .m_showDiscussionsSearch__courseID_courseIDisBlackedOut_isBlackedOut(`courseID`, `isBlackedOut`))} + public static func showComments(commentID: Parameter, parentComment: Parameter, threadStateSubject: Parameter>, isBlackedOut: Parameter, animated: Parameter) -> Verify { return Verify(method: .m_showComments__commentID_commentIDparentComment_parentCommentthreadStateSubject_threadStateSubjectisBlackedOut_isBlackedOutanimated_animated(`commentID`, `parentComment`, `threadStateSubject`, `isBlackedOut`, `animated`))} public static func createNewThread(courseID: Parameter, selectedTopic: Parameter, onPostCreated: Parameter<() -> Void>) -> Verify { return Verify(method: .m_createNewThread__courseID_courseIDselectedTopic_selectedTopiconPostCreated_onPostCreated(`courseID`, `selectedTopic`, `onPostCreated`))} 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`))} @@ -2886,17 +2913,17 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { public static func showUserDetails(username: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_showUserDetails__username_username(`username`), performs: perform) } - public static func showThreads(courseID: Parameter, topics: Parameter, title: Parameter, type: Parameter, animated: Parameter, perform: @escaping (String, Topics, String, ThreadType, Bool) -> Void) -> Perform { - return Perform(method: .m_showThreads__courseID_courseIDtopics_topicstitle_titletype_typeanimated_animated(`courseID`, `topics`, `title`, `type`, `animated`), performs: perform) + public static func showThreads(courseID: Parameter, topics: Parameter, title: Parameter, type: Parameter, isBlackedOut: Parameter, animated: Parameter, perform: @escaping (String, Topics, String, ThreadType, Bool, Bool) -> Void) -> Perform { + return Perform(method: .m_showThreads__courseID_courseIDtopics_topicstitle_titletype_typeisBlackedOut_isBlackedOutanimated_animated(`courseID`, `topics`, `title`, `type`, `isBlackedOut`, `animated`), performs: perform) } - public static func showThread(thread: Parameter, postStateSubject: Parameter>, animated: Parameter, perform: @escaping (UserThread, CurrentValueSubject, Bool) -> Void) -> Perform { - return Perform(method: .m_showThread__thread_threadpostStateSubject_postStateSubjectanimated_animated(`thread`, `postStateSubject`, `animated`), performs: perform) + public static func showThread(thread: Parameter, postStateSubject: Parameter>, isBlackedOut: Parameter, animated: Parameter, perform: @escaping (UserThread, CurrentValueSubject, Bool, Bool) -> Void) -> Perform { + return Perform(method: .m_showThread__thread_threadpostStateSubject_postStateSubjectisBlackedOut_isBlackedOutanimated_animated(`thread`, `postStateSubject`, `isBlackedOut`, `animated`), performs: perform) } - public static func showDiscussionsSearch(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { - return Perform(method: .m_showDiscussionsSearch__courseID_courseID(`courseID`), performs: perform) + public static func showDiscussionsSearch(courseID: Parameter, isBlackedOut: Parameter, perform: @escaping (String, Bool) -> Void) -> Perform { + return Perform(method: .m_showDiscussionsSearch__courseID_courseIDisBlackedOut_isBlackedOut(`courseID`, `isBlackedOut`), performs: perform) } - public static func showComments(commentID: Parameter, parentComment: Parameter, threadStateSubject: Parameter>, animated: Parameter, perform: @escaping (String, Post, CurrentValueSubject, Bool) -> Void) -> Perform { - return Perform(method: .m_showComments__commentID_commentIDparentComment_parentCommentthreadStateSubject_threadStateSubjectanimated_animated(`commentID`, `parentComment`, `threadStateSubject`, `animated`), performs: perform) + public static func showComments(commentID: Parameter, parentComment: Parameter, threadStateSubject: Parameter>, isBlackedOut: Parameter, animated: Parameter, perform: @escaping (String, Post, CurrentValueSubject, Bool, Bool) -> Void) -> Perform { + return Perform(method: .m_showComments__commentID_commentIDparentComment_parentCommentthreadStateSubject_threadStateSubjectisBlackedOut_isBlackedOutanimated_animated(`commentID`, `parentComment`, `threadStateSubject`, `isBlackedOut`, `animated`), performs: perform) } public static func createNewThread(courseID: Parameter, selectedTopic: Parameter, onPostCreated: Parameter<() -> Void>, perform: @escaping (String, String, @escaping () -> Void) -> Void) -> Perform { return Perform(method: .m_createNewThread__courseID_courseIDselectedTopic_selectedTopiconPostCreated_onPostCreated(`courseID`, `selectedTopic`, `onPostCreated`), performs: perform) diff --git a/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift b/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift index e411e7fcd..32f957a85 100644 --- a/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift @@ -35,7 +35,9 @@ final class DiscussionTopicsViewModelTests: XCTestCase { CoursewareTopics(id: "66", name: "66", threadListURL: "66", children: []) ]) ]) - + + let discussionInfo = DiscussionInfo(discussionID: "1", blackouts: []) + func testGetTopicsSuccess() async throws { let interactor = DiscussionInteractorProtocolMock() let router = DiscussionRouterMock() @@ -46,13 +48,15 @@ final class DiscussionTopicsViewModelTests: XCTestCase { router: router, analytics: analytics, config: config) - + Given(interactor, .getTopics(courseID: .any, willReturn: topics)) - + Given(interactor, .getCourseDiscussionInfo(courseID: .any, willReturn: discussionInfo)) + await viewModel.getTopics(courseID: "1") - + Verify(interactor, .getTopics(courseID: .any)) - + Verify(interactor, .getCourseDiscussionInfo(courseID: .any)) + XCTAssertNotNil(viewModel.topics) XCTAssertNotNil(viewModel.discussionTopics) XCTAssertFalse(viewModel.showError) @@ -70,15 +74,17 @@ final class DiscussionTopicsViewModelTests: XCTestCase { router: router, analytics: analytics, config: config) - + let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) Given(interactor, .getTopics(courseID: .any, willThrow: noInternetError)) - + Given(interactor, .getCourseDiscussionInfo(courseID: .any, willReturn: discussionInfo)) + await viewModel.getTopics(courseID: "1") + Verify(interactor, .getCourseDiscussionInfo(courseID: .any)) Verify(interactor, .getTopics(courseID: .any)) - + XCTAssertNil(viewModel.topics) XCTAssertNil(viewModel.discussionTopics) XCTAssertTrue(viewModel.showError) @@ -98,11 +104,13 @@ final class DiscussionTopicsViewModelTests: XCTestCase { config: config) Given(interactor, .getTopics(courseID: .any, willThrow: NSError())) - + Given(interactor, .getCourseDiscussionInfo(courseID: .any, willReturn: discussionInfo)) + await viewModel.getTopics(courseID: "1") + Verify(interactor, .getCourseDiscussionInfo(courseID: .any)) Verify(interactor, .getTopics(courseID: .any)) - + XCTAssertNil(viewModel.topics) XCTAssertNil(viewModel.discussionTopics) XCTAssertTrue(viewModel.showError) diff --git a/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift b/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift index d079c8944..052930f5b 100644 --- a/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift @@ -100,7 +100,10 @@ final class PostViewModelTests: XCTestCase { hasEndorsed: true, numPages: 4), ]) - + + let discussionInfo = DiscussionInfo(discussionID: "1", blackouts: []) + + func testGetThreadListSuccess() async throws { let interactor = DiscussionInteractorProtocolMock() let router = DiscussionRouterMock() @@ -112,6 +115,7 @@ final class PostViewModelTests: XCTestCase { viewModel.type = .allPosts Given(interactor, .getThreadsList(courseID: .any, type: .any, sort: .any, filter: .any, page: .any, willReturn: threads)) + Given(interactor, .getCourseDiscussionInfo(courseID: .any, willReturn: discussionInfo)) viewModel.type = .allPosts result = await viewModel.getPosts(pageNumber: 1) @@ -146,10 +150,12 @@ final class PostViewModelTests: XCTestCase { let config = ConfigMock() var result = false let viewModel = PostsViewModel(interactor: interactor, router: router, config: config) - + viewModel.isBlackedOut = false + let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) Given(interactor, .getThreadsList(courseID: .any, type: .any, sort: .any, filter: .any, page: .any, willThrow: noInternetError)) + Given(interactor, .getCourseDiscussionInfo(courseID: .any, willThrow: noInternetError)) viewModel.courseID = "1" viewModel.type = .allPosts @@ -169,8 +175,10 @@ final class PostViewModelTests: XCTestCase { let config = ConfigMock() var result = false let viewModel = PostsViewModel(interactor: interactor, router: router, config: config) + viewModel.isBlackedOut = false Given(interactor, .getThreadsList(courseID: .any, type: .any, sort: .any, filter: .any, page: .any, willThrow: NSError())) + Given(interactor, .getCourseDiscussionInfo(courseID: .any, willThrow: NSError())) viewModel.courseID = "1" viewModel.type = .allPosts @@ -192,6 +200,8 @@ final class PostViewModelTests: XCTestCase { Given(interactor, .getThreadsList(courseID: .any, type: .any, sort: .any, filter: .any, page: .any, willReturn: threads)) + Given(interactor, .getCourseDiscussionInfo(courseID: "1", willReturn: discussionInfo)) + viewModel.courseID = "1" viewModel.type = .allPosts viewModel.sortTitle = .mostActivity From 36c0cbd8f8859717f2c18820d9ec157873802b67 Mon Sep 17 00:00:00 2001 From: Omar Al-Ithawi Date: Thu, 28 Mar 2024 12:25:13 +0300 Subject: [PATCH 095/136] docs: reformat 0001-strategy-for-maintaining-OS-versions.rst (#378) --- ...1-strategy-for-maintaining-OS-versions.rst | 40 +++++++++++-------- 1 file changed, 24 insertions(+), 16 deletions(-) diff --git a/docs/0001-strategy-for-maintaining-OS-versions.rst b/docs/0001-strategy-for-maintaining-OS-versions.rst index 17aea4b7d..30b6f2da0 100644 --- a/docs/0001-strategy-for-maintaining-OS-versions.rst +++ b/docs/0001-strategy-for-maintaining-OS-versions.rst @@ -1,62 +1,70 @@ -Title: Strategy for maintaining iOS versions in the OpenEdx Project -================================================== +Strategy for maintaining iOS versions in the Open edX Project +############################################################# + Date: 13 September 2023 Status ------- +****** Accepted Context ------- -In the OpenEdx project, we are developing a mobile application on the SwiftUI platform for iOS users. +******* +In the Open edX project, we are developing a mobile application on the SwiftUI platform for iOS users. To ensure optimal support and security of the application, we need to make a decision regarding which versions of the iOS operating system will be supported. This document outlines the decision to support only the current iOS version and the two previous versions. Decision ------- -We decide to support only the current iOS version and the two previous versions. This means that our +******** + +We decide to support only the current iOS version and the two previous versions. This means that our application will be optimized and tested to work on the three most recent iOS versions at the time of the application's release. Why is this important? 1. Streamlined Development and Testing ------- +====================================== + Supporting multiple iOS versions requires significant development and testing resources. By restricting the number of supported versions, we can focus our efforts on developing new features and improving the application's quality, without spreading ourselves too thin trying to maintain compatibility with outdated iOS versions. 2. Performance and User Experience Enhancement ------- +============================================== + With each new iOS version, Apple introduces performance and functionality improvements. By limiting support to older iOS versions, we can leverage new capabilities and libraries to create faster and more feature-rich application versions. This also enhances the user experience and user satisfaction. 3. Security ------- +=========== + The most crucial aspect of mobile application development is ensuring user security. New iOS versions contain critical security updates, and supporting old iOS versions can leave the application vulnerable to known threats. By supporting only the current version and the two previous ones, we can quickly respond to security updates and provide robust data protection for users. Project Impact ------- +************** This decision will impact the project in the following ways: ------- -Enhanced application security. -Improved performance and functionality. -Reduced development and testing burden. + +- Enhanced application security. +- Improved performance and functionality. +- Reduced development and testing burden. + Implementation +************** To implement this decision, we will monitor the releases of new iOS versions and update our application accordingly, considering the limitation of supporting only the current version and the two previous versions. We will also inform users about the need to update their operating systems for optimal application performance. Alternatives ------- +************ + Continuing to support older iOS versions would demand more resources, pose security and performance risks, and limit our ability to adopt modern technologies and innovations, potentially slowing down development and compromising user experience. From cf443565c8df43395b1fca69360af17422007d68 Mon Sep 17 00:00:00 2001 From: forgotvas Date: Thu, 28 Mar 2024 17:58:15 +0300 Subject: [PATCH 096/136] fix: pip restoration --- Course/Course/Presentation/CourseRouter.swift | 4 - .../Outline/CourseOutlineView.swift | 1 - .../CourseStructureNestedListView.swift | 1 - .../CourseVertical/CourseVerticalView.swift | 1 - .../Unit/CourseNavigationView.swift | 5 - .../Presentation/Unit/CourseUnitView.swift | 11 +- .../Unit/CourseUnitViewModel.swift | 64 ++++- .../Video/EncodedVideoPlayer.swift | 2 +- .../Video/EncodedVideoPlayerViewModel.swift | 6 +- .../Video/PlayerViewControllerHolder.swift | 43 +-- OpenEdX/DI/ScreenAssembly.swift | 2 +- .../DeepLinkRouter/DeepLinkRouter.swift | 8 +- OpenEdX/Managers/PipManager.swift | 258 +++++++++++++++--- OpenEdX/Router.swift | 11 +- 14 files changed, 313 insertions(+), 104 deletions(-) diff --git a/Course/Course/Presentation/CourseRouter.swift b/Course/Course/Presentation/CourseRouter.swift index d4cd7c68a..50b2cdef1 100644 --- a/Course/Course/Presentation/CourseRouter.swift +++ b/Course/Course/Presentation/CourseRouter.swift @@ -16,7 +16,6 @@ public protocol CourseRouter: BaseRouter { courseName: String, blockId: String, courseID: String, - sectionName: String, verticalIndex: Int, chapters: [CourseChapter], chapterIndex: Int, @@ -27,7 +26,6 @@ public protocol CourseRouter: BaseRouter { courseName: String, blockId: String, courseID: String, - sectionName: String, verticalIndex: Int, chapters: [CourseChapter], chapterIndex: Int, @@ -74,7 +72,6 @@ public class CourseRouterMock: BaseRouterMock, CourseRouter { courseName: String, blockId: String, courseID: String, - sectionName: String, verticalIndex: Int, chapters: [CourseChapter], chapterIndex: Int, @@ -85,7 +82,6 @@ public class CourseRouterMock: BaseRouterMock, CourseRouter { courseName: String, blockId: String, courseID: String, - sectionName: String, verticalIndex: Int, chapters: [CourseChapter], chapterIndex: Int, diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index b4ebb132e..227be89ed 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -105,7 +105,6 @@ public struct CourseOutlineView: View { courseName: course.displayName, blockId: continueBlock?.id ?? "", courseID: course.id, - sectionName: continueUnit.displayName, verticalIndex: continueWith.verticalIndex, chapters: course.childs, chapterIndex: continueWith.chapterIndex, diff --git a/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift b/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift index e057b3b7c..4e5e853df 100644 --- a/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift +++ b/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift @@ -224,7 +224,6 @@ struct CourseStructureNestedListView: View { courseName: viewModel.courseStructure?.displayName ?? "", blockId: block.id, courseID: viewModel.courseStructure?.id ?? "", - sectionName: block.displayName, verticalIndex: 0, chapters: course.childs, chapterIndex: chapterIndex, diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift index 889d6e155..2cbdc7e9e 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift @@ -54,7 +54,6 @@ public struct CourseVerticalView: View { courseName: courseName, blockId: block.id, courseID: courseID, - sectionName: block.displayName, verticalIndex: index, chapters: viewModel.chapters, chapterIndex: viewModel.chapterIndex, diff --git a/Course/Course/Presentation/Unit/CourseNavigationView.swift b/Course/Course/Presentation/Unit/CourseNavigationView.swift index 0834019e3..0dea684b8 100644 --- a/Course/Course/Presentation/Unit/CourseNavigationView.swift +++ b/Course/Course/Presentation/Unit/CourseNavigationView.swift @@ -13,16 +13,13 @@ struct CourseNavigationView: View { @ObservedObject private var viewModel: CourseUnitViewModel - private let sectionName: String private let playerStateSubject: CurrentValueSubject init( - sectionName: String, viewModel: CourseUnitViewModel, playerStateSubject: CurrentValueSubject ) { self.viewModel = viewModel - self.sectionName = sectionName self.playerStateSubject = playerStateSubject } @@ -129,7 +126,6 @@ struct CourseNavigationView: View { courseName: viewModel.courseName, blockId: viewModel.lessonID, courseID: viewModel.courseID, - sectionName: viewModel.selectedLesson().displayName, verticalIndex: data.verticalIndex, chapters: viewModel.chapters, chapterIndex: data.chapterIndex, @@ -171,7 +167,6 @@ struct CourseNavigationView_Previews: PreviewProvider { ) CourseNavigationView( - sectionName: "Name", viewModel: viewModel, playerStateSubject: CurrentValueSubject(nil) ) diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index 73b529d32..1219cea91 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -14,7 +14,7 @@ import Theme public struct CourseUnitView: View { - @ObservedObject private var viewModel: CourseUnitViewModel + @ObservedObject public var viewModel: CourseUnitViewModel @State private var showAlert: Bool = false @State var alertMessage: String? { didSet { @@ -27,7 +27,6 @@ public struct CourseUnitView: View { @State var showDiscussion: Bool = false @Environment(\.isPresented) private var isPresented @Environment(\.isHorizontal) private var isHorizontal - private let sectionName: String public let playerStateSubject = CurrentValueSubject(nil) //Dropdown parameters @@ -60,11 +59,9 @@ public struct CourseUnitView: View { public init( viewModel: CourseUnitViewModel, - sectionName: String, isDropdownActive: Bool = false ) { self.viewModel = viewModel - self.sectionName = sectionName self.isDropdownActive = isDropdownActive viewModel.loadIndex() viewModel.nextTitles() @@ -122,7 +119,8 @@ public struct CourseUnitView: View { offsetY: isHorizontal ? landscapeTopSpacing : portraitTopSpacing, showDropdown: $showDropdown ) { [weak viewModel] vertical in - viewModel?.route(to: vertical) + let data = viewModel?.dataFor(blockId: vertical.childs.first?.id) + viewModel?.route(to: data) } } } @@ -413,7 +411,6 @@ public struct CourseUnitView: View { Spacer() } CourseNavigationView( - sectionName: sectionName, viewModel: viewModel, playerStateSubject: playerStateSubject ) @@ -558,7 +555,7 @@ struct CourseUnitView_Previews: PreviewProvider { connectivity: Connectivity(), storage: CourseStorageMock(), manager: DownloadManagerMock() - ), sectionName: "") + )) } } //swiftlint:enable all diff --git a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift index d272d3679..ca18d3250 100644 --- a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift +++ b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift @@ -31,7 +31,8 @@ public enum LessonType: Equatable { case .discussion: return .discussion(block.topicId ?? "", block.id, block.displayName) case .video: - if block.encodedVideo?.youtubeVideoUrl != nil, let encodedVideo = block.encodedVideo?.video(streamingQuality: streamingQuality)?.url { + if block.encodedVideo?.youtubeVideoUrl != nil, + let encodedVideo = block.encodedVideo?.video(streamingQuality: streamingQuality)?.url { return .video(videoUrl: encodedVideo, blockID: block.id) } else if let youtubeVideoUrl = block.encodedVideo?.youtubeVideoUrl { return .youtube(youtubeVideoUrl: youtubeVideoUrl, blockID: block.id) @@ -64,6 +65,7 @@ public class CourseUnitViewModel: ObservableObject { var chapterIndex: Int var sequentialIndex: Int var verticalIndex: Int + var blockIndex: Int } var verticals: [CourseVertical] @@ -95,7 +97,7 @@ public class CourseUnitViewModel: ObservableObject { let chapterIndex: Int let sequentialIndex: Int - var streamingQuality: StreamingQuality { + var streamingQuality: StreamingQuality { storage.userSettings?.streamingQuality ?? .auto } @@ -221,7 +223,8 @@ public class CourseUnitViewModel: ObservableObject { from: VerticalData( chapterIndex: chapterIndex, sequentialIndex: sequentialIndex, - verticalIndex: verticalIndex + verticalIndex: verticalIndex, + blockIndex: 0 ) ) } @@ -271,6 +274,7 @@ public class CourseUnitViewModel: ObservableObject { } if let vertical = vertical(for: resultData), vertical.childs.count > 0 { + resultData.blockIndex = 0 return resultData } else { return nextData(from: resultData) @@ -291,20 +295,58 @@ public class CourseUnitViewModel: ObservableObject { ) } - func route(to vertical: CourseVertical) { - if let index = verticals.firstIndex(where: { $0.id == vertical.id }), - let block = vertical.childs.first { + func blockFor(index: Int, in vertical: CourseVertical) -> CourseBlock? { + guard index >= 0 && index < vertical.childs.count else { return nil } + return vertical.childs[index] + } + + func route(to data: VerticalData?, animated: Bool = false) { + guard let data = data else { return } + if data.verticalIndex == verticalIndex, + let block = blockFor(index: data.blockIndex, in: verticals[verticalIndex]) { + // if we are on same vertical now + lessonID = block.blockId + loadIndex() + } else if let vertical = vertical(for: data), + let block = blockFor(index: data.blockIndex, in: vertical) { router.replaceCourseUnit( courseName: courseName, blockId: block.id, courseID: courseID, - sectionName: block.displayName, - verticalIndex: index, + verticalIndex: data.verticalIndex, chapters: chapters, - chapterIndex: chapterIndex, - sequentialIndex: sequentialIndex, - animated: false + chapterIndex: data.chapterIndex, + sequentialIndex: data.sequentialIndex, + animated: animated ) } } + + public func route(to blockId: String?) { + guard let data = dataFor(blockId: blockId) else { return } + route(to: data, animated: true) + } + + func dataFor(blockId: String?) -> VerticalData? { + guard let blockId = blockId else { return nil } + for (chapterIndex, chapter) in chapters.enumerated() { + for (sequentialIndex, sequential) in chapter.childs.enumerated() { + for (verticalIndex, vertical) in sequential.childs.enumerated() { + for (blockIndex, block) in vertical.childs.enumerated() where block.id.contains(blockId) { + return VerticalData( + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex, + verticalIndex: verticalIndex, + blockIndex: blockIndex + ) + } + } + } + } + return nil + } + + public var currentCourseId: String { + courseID + } } diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift index af27fd4ac..c3f64dc17 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift @@ -153,7 +153,7 @@ struct EncodedVideoPlayer_Previews: PreviewProvider { appStorage: CoreStorageMock(), connectivity: Connectivity(), pipManager: PipManagerProtocolMock(), - isVideoTab: false + selectedCourseTab: 0 ), isOnScreen: true ) diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift index 0f6ba0cca..5f4121c2c 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift @@ -31,7 +31,7 @@ public class EncodedVideoPlayerViewModel: VideoPlayerViewModel { appStorage: CoreStorage, connectivity: ConnectivityProtocol, pipManager: PipManagerProtocol, - isVideoTab: Bool + selectedCourseTab: Int ) { self.url = url @@ -39,7 +39,7 @@ public class EncodedVideoPlayerViewModel: VideoPlayerViewModel { for: url, blockID: blockID, courseID: courseID, - isVideoTab: isVideoTab + selectedCourseTab: selectedCourseTab ) { print("ALARM restore holder") controllerHolder = holder @@ -49,7 +49,7 @@ public class EncodedVideoPlayerViewModel: VideoPlayerViewModel { url: url, blockID: blockID, courseID: courseID, - isVideoTab: isVideoTab + selectedCourseTab: selectedCourseTab ) controllerHolder = holder } diff --git a/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift b/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift index 2393d7e82..c724995ca 100644 --- a/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift +++ b/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift @@ -10,11 +10,10 @@ import Combine import Swinject public protocol PipManagerProtocol { - func holder(for url: URL?, blockID: String, courseID: String, isVideoTab: Bool) -> PlayerViewControllerHolder? + func holder(for url: URL?, blockID: String, courseID: String, selectedCourseTab: Int) -> PlayerViewControllerHolder? func set(holder: PlayerViewControllerHolder) func remove(holder: PlayerViewControllerHolder) func restore(holder: PlayerViewControllerHolder) async throws - func appearancePublisher(for holder: PlayerViewControllerHolder) -> AnyPublisher? } #if DEBUG @@ -24,16 +23,13 @@ public class PipManagerProtocolMock: PipManagerProtocol { for url: URL?, blockID: String, courseID: String, - isVideoTab: Bool + selectedCourseTab: Int ) -> PlayerViewControllerHolder? { return nil } public func set(holder: PlayerViewControllerHolder) {} public func remove(holder: PlayerViewControllerHolder) {} public func restore(holder: PlayerViewControllerHolder) async throws {} - public func appearancePublisher(for holder: PlayerViewControllerHolder) -> AnyPublisher? { - return nil - } } #endif @@ -41,7 +37,7 @@ public class PlayerViewControllerHolder: NSObject, AVPlayerViewControllerDelegat public let url: URL? public let blockID: String public let courseID: String - public let isVideoTab: Bool + public let selectedCourseTab: Int public var isPipModeActive: Bool = false public lazy var playerController: AVPlayerViewController = { @@ -54,24 +50,27 @@ public class PlayerViewControllerHolder: NSObject, AVPlayerViewControllerDelegat url: URL?, blockID: String, courseID: String, - isVideoTab: Bool + selectedCourseTab: Int ) { self.url = url self.blockID = blockID self.courseID = courseID - self.isVideoTab = isVideoTab + self.selectedCourseTab = selectedCourseTab } - + public func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) { isPipModeActive = true Container.shared.resolve(PipManagerProtocol.self)?.set(holder: self) } - + // func playerViewControllerDidStartPictureInPicture(_ playerViewController: AVPlayerViewController) { // // } - - public func playerViewController(_ playerViewController: AVPlayerViewController, failedToStartPictureInPictureWithError error: any Error) { + + public func playerViewController( + _ playerViewController: AVPlayerViewController, + failedToStartPictureInPictureWithError error: any Error + ) { isPipModeActive = false Container.shared.resolve(PipManagerProtocol.self)?.remove(holder: self) print("ALARM failed to start \(error)") @@ -86,7 +85,9 @@ public class PlayerViewControllerHolder: NSObject, AVPlayerViewControllerDelegat // func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) { // // } - public func playerViewControllerRestoreUserInterfaceForPictureInPictureStop(_ playerViewController: AVPlayerViewController) async -> Bool { + public func playerViewControllerRestoreUserInterfaceForPictureInPictureStop( + _ playerViewController: AVPlayerViewController + ) async -> Bool { print("ALARM restore controller") do { try await Container.shared.resolve(PipManagerProtocol.self)?.restore(holder: self) @@ -98,11 +99,13 @@ public class PlayerViewControllerHolder: NSObject, AVPlayerViewControllerDelegat } } - static func == (lhs: PlayerViewControllerHolder, rhs: PlayerViewControllerHolder) -> Bool { - lhs.url?.absoluteString == rhs.url?.absoluteString && - lhs.courseID == rhs.courseID && - lhs.blockID == rhs.blockID && - lhs.isVideoTab == rhs.isVideoTab + public override func isEqual(_ object: Any?) -> Bool { + guard let object = object as? PlayerViewControllerHolder else { + return false + } + return url?.absoluteString == object.url?.absoluteString && + courseID == object.courseID && + blockID == object.blockID && + selectedCourseTab == object.selectedCourseTab } } - diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 414db26f2..7a17c656d 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -339,7 +339,7 @@ class ScreenAssembly: Assembly { appStorage: r.resolve(CoreStorage.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, pipManager: r.resolve(PipManagerProtocol.self)!, - isVideoTab: router.isVideoTab + selectedCourseTab: router.currentCourseTabSelection ) } diff --git a/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift b/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift index 83109d73f..e2515d36d 100644 --- a/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift +++ b/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift @@ -57,11 +57,6 @@ public protocol DeepLinkRouter: BaseRouter { extension Router: DeepLinkRouter { // MARK: - DeepLinkRouter - - public var isVideoTab: Bool { - self.hostCourseContainerView?.rootView.viewModel.selection == CourseTab.videos.rawValue - } - public func showDiscoveryDetails( link: DeepLink, pathID: String @@ -313,6 +308,9 @@ extension Router: DeepLinkRouter { backToRoot(animated: false) } + public var currentCourseTabSelection: Int { + self.hostCourseContainerView?.rootView.viewModel.selection ?? 0 + } } // Mark - For testing and SwiftUI preview diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift index cdcd575d5..d25095766 100644 --- a/OpenEdX/Managers/PipManager.swift +++ b/OpenEdX/Managers/PipManager.swift @@ -9,12 +9,13 @@ import Combine import Course import Discovery import Foundation +import SwiftUI +import Swinject +import Core public class PipManager: PipManagerProtocol { var controllerHolder: PlayerViewControllerHolder? - private var appearancePublisher = PassthroughSubject() private var restorationTask: Task? - private var cancellations: [AnyCancellable] = [] let discoveryInteractor: DiscoveryInteractorProtocol let courseInteractor: CourseInteractorProtocol let router: Router @@ -27,26 +28,25 @@ public class PipManager: PipManagerProtocol { self.courseInteractor = courseInteractor self.router = router } - + public func holder( for url: URL?, blockID: String, courseID: String, - isVideoTab: Bool + selectedCourseTab: Int ) -> PlayerViewControllerHolder? { + print("ALARM navigationStack: \(router.getNavigationController().children)") if controllerHolder?.blockID == blockID, controllerHolder?.courseID == courseID, - controllerHolder?.isVideoTab == isVideoTab { + controllerHolder?.selectedCourseTab == selectedCourseTab { return controllerHolder } - + return nil } public func set(holder: PlayerViewControllerHolder) { controllerHolder = holder - appearancePublisher = PassthroughSubject() - cancellations.removeAll() restorationTask?.cancel() restorationTask = nil } @@ -62,52 +62,236 @@ public class PipManager: PipManagerProtocol { @MainActor public func restore(holder: PlayerViewControllerHolder) async throws { let courseID = holder.courseID - var courseDetails: CourseDetails? - if let value = try? await discoveryInteractor.getLoadedCourseDetails( - courseID: courseID - ) { - courseDetails = value + // if we are on CourseUnitView, and tab is same with holder + if let controller = topCourseUnitController, + router.currentCourseTabSelection == holder.selectedCourseTab { + let viewModel = controller.rootView.viewModel + + if viewModel.currentCourseId == courseID { + viewModel.route(to: holder.blockID) + return + } + } + + + try await navigate(to: holder) + } + + @MainActor + func navigate(to holder: PlayerViewControllerHolder) async throws { + let currentControllers = router.getNavigationController().viewControllers + guard let mainController = currentControllers.first as? UIHostingController else { + return + } + + mainController.rootView.viewModel.select(tab: .dashboard) + + var viewControllers: [UIViewController] = [mainController] + if currentControllers.count > 1, + let containerController = currentControllers[1] as? UIHostingController, + containerController.rootView.courseID == holder.courseID { + containerController.rootView.viewModel.selection = holder.selectedCourseTab + viewControllers.append(containerController) } else { - courseDetails = try await discoveryInteractor.getCourseDetails( - courseID: courseID - ) + viewControllers.append(try await containerController(for: holder)) } - guard let courseDetails = courseDetails else { throw PipManagerError.cantGetCourseDetails } - let link = DeepLink(dictionary: [:]) - link.type = holder.isVideoTab ? .courseVideos : .courseDashboard - await showCourseDetail(link: link, courseDetails: courseDetails) - var courseStructure = try await courseInteractor.getLoadedCourseBlocks(courseID: courseID) - if holder.isVideoTab { + viewControllers.append(try await courseUnitController(for: holder)) + + router.getNavigationController().setViewControllers(viewControllers, animated: true) + } + + @MainActor func courseUnitController( + for holder: PlayerViewControllerHolder + ) async throws -> UIHostingController { + + var courseStructure = try await courseInteractor.getLoadedCourseBlocks(courseID: holder.courseID) + if holder.selectedCourseTab == CourseTab.videos.rawValue { courseStructure = courseInteractor.getCourseVideoBlocks(fullStructure: courseStructure) } - router.showCourseComponent(componentID: holder.blockID, courseStructure: courseStructure) + for (chapterIndex, chapter) in courseStructure.childs.enumerated() { + for (sequentialIndex, sequential) in chapter.childs.enumerated() { + for (verticalIndex, vertical) in sequential.childs.enumerated() { + for (_, block) in vertical.childs.enumerated() where block.id == holder.blockID { + let viewModel = Container.shared.resolve( + CourseUnitViewModel.self, + arguments: block.blockId, + courseStructure.id, + courseStructure.displayName, + courseStructure.childs, + chapterIndex, + sequentialIndex, + verticalIndex + )! + + let config = Container.shared.resolve(ConfigProtocol.self) + let isDropdownActive = config?.uiComponents.courseNestedListEnabled ?? false + + let view = CourseUnitView(viewModel: viewModel, isDropdownActive: isDropdownActive) + return UIHostingController(rootView: view) + } + } + } + } + + throw PipManagerError.cantCreateCourseUnitView } @MainActor - func showCourseDetail(link: DeepLink, courseDetails: CourseDetails) async { - await withCheckedContinuation { continuation in - router.showCourseDetail( - link: link, - courseDetails: courseDetails - ) { - continuation.resume() - } - } + func containerController( + for holder: PlayerViewControllerHolder + ) async throws -> UIHostingController { + let courseDetails = try await getCourseDetails(for: holder) + let isActive: Bool? = nil + + let vm = Container.shared.resolve( + CourseContainerViewModel.self, + arguments: isActive, + courseDetails.courseStart, + courseDetails.courseEnd, + courseDetails.enrollmentStart, + courseDetails.enrollmentEnd + )! + let screensView = CourseContainerView( + viewModel: vm, + courseID: courseDetails.courseID, + title: courseDetails.courseTitle + ) + + let controller = UIHostingController(rootView: screensView) + controller.rootView.viewModel.selection = holder.selectedCourseTab + return controller } - public func appearancePublisher(for holder: Course.PlayerViewControllerHolder) -> AnyPublisher? { - if holder == controllerHolder { - return appearancePublisher - .eraseToAnyPublisher() + func getCourseDetails(for holder: PlayerViewControllerHolder) async throws -> CourseDetails { + if let value = try? await discoveryInteractor.getLoadedCourseDetails( + courseID: holder.courseID + ) { + return value + } else { + return try await discoveryInteractor.getCourseDetails( + courseID: holder.courseID + ) } - return nil + } + /* + let isCourseOpened = hostCourseContainerView?.rootView.courseID == courseDetails.courseID + + if !isCourseOpened { + showTabScreen(tab: .dashboard) + + if courseDetails.isEnrolled { + showCourseScreens( + courseID: courseDetails.courseID, + isActive: nil, + courseStart: courseDetails.courseStart, + courseEnd: courseDetails.courseEnd, + enrollmentStart: courseDetails.enrollmentStart, + enrollmentEnd: courseDetails.enrollmentEnd, + title: courseDetails.courseTitle + ) + } else { + showCourseDetais( + courseID: courseDetails.courseID, + title: courseDetails.courseTitle + ) + } + } + + switch link.type { + case .courseVideos, + .courseDates, + .discussions, + .courseHandout, + .courseAnnouncement, + .courseDashboard: + popToCourseContainerView(animated: false) + default: + break + } + + DispatchQueue.main.asyncAfter(deadline: .now() + (isCourseOpened ? 0 : 1)) { + switch link.type { + case .courseDashboard: + self.hostCourseContainerView?.rootView.viewModel.selection = CourseTab.course.rawValue + case .courseVideos: + self.hostCourseContainerView?.rootView.viewModel.selection = CourseTab.videos.rawValue + case .courseDates: + self.hostCourseContainerView?.rootView.viewModel.selection = CourseTab.dates.rawValue + case .discussions, .discussionTopic, .discussionPost, .discussionComment: + self.hostCourseContainerView?.rootView.viewModel.selection = CourseTab.discussion.rawValue + case .courseHandout, .courseAnnouncement: + self.hostCourseContainerView?.rootView.viewModel.selection = CourseTab.handounds.rawValue + default: + break + } + + completion() + } + + public func showCourseScreens( + courseID: String, + isActive: Bool?, + courseStart: Date?, + courseEnd: Date?, + enrollmentStart: Date?, + enrollmentEnd: Date?, + title: String + ) { + let vm = Container.shared.resolve( + CourseContainerViewModel.self, + arguments: isActive, + courseStart, + courseEnd, + enrollmentStart, + enrollmentEnd + )! + let screensView = CourseContainerView( + viewModel: vm, + courseID: courseID, + title: title + ) + + let controller = UIHostingController(rootView: screensView) + navigationController.pushViewController(controller, animated: true) + } + + */ + + private var topCourseUnitController: UIHostingController? { + router.getNavigationController().visibleViewController as? UIHostingController } } extension PipManager { enum PipManagerError: Error { case cantGetCourseDetails + case cantCreateCourseUnitView + } +} + +extension UIViewController { + public var mostTopController: UIViewController? { + topController(from: self) + } + + private func topController(from controller: UIViewController?) -> UIViewController? { + if let navigationController = controller as? UINavigationController { + return topController(from: navigationController.visibleViewController) + } else if let tabBarController = controller as? UITabBarController { + return topController(from: tabBarController.selectedViewController) + } else if let splitController = controller as? UISplitViewController { + return topController(from: splitController.viewControllers.last) + } else { + if let presentedController = controller?.presentedViewController { + return topController(from: presentedController) + } else { + if let child = controller?.children.last { + return topController(from: child) + } + return controller + } + } } } diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 8e9667458..009cd19d6 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -378,7 +378,6 @@ public class Router: AuthorizationRouter, courseName: String, blockId: String, courseID: String, - sectionName: String, verticalIndex: Int, chapters: [CourseChapter], chapterIndex: Int, @@ -398,7 +397,7 @@ public class Router: AuthorizationRouter, let config = Container.shared.resolve(ConfigProtocol.self) let isDropdownActive = config?.uiComponents.courseNestedListEnabled ?? false - let view = CourseUnitView(viewModel: viewModel, sectionName: sectionName, isDropdownActive: isDropdownActive) + let view = CourseUnitView(viewModel: viewModel, isDropdownActive: isDropdownActive) let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) } @@ -417,7 +416,6 @@ public class Router: AuthorizationRouter, courseName: courseStructure.displayName, blockId: block.blockId, courseID: courseStructure.id, - sectionName: sequential.displayName, verticalIndex: verticalIndex, chapters: courseStructure.childs, chapterIndex: chapterIndex, @@ -444,7 +442,6 @@ public class Router: AuthorizationRouter, courseName: String, blockId: String, courseID: String, - sectionName: String, verticalIndex: Int, chapters: [CourseChapter], chapterIndex: Int, @@ -480,13 +477,13 @@ public class Router: AuthorizationRouter, let config = Container.shared.resolve(ConfigProtocol.self) let isDropdownActive = config?.uiComponents.courseNestedListEnabled ?? false + let isCourseNestedListEnabled = config?.uiComponents.courseNestedListEnabled ?? false - let view = CourseUnitView(viewModel: viewModel, sectionName: sectionName, isDropdownActive: isDropdownActive) + let view = CourseUnitView(viewModel: viewModel, isDropdownActive: isDropdownActive) let controllerUnit = UIHostingController(rootView: view) var controllers = navigationController.viewControllers - if let config = container.resolve(ConfigProtocol.self), - config.uiComponents.courseNestedListEnabled { + if isCourseNestedListEnabled || currentCourseTabSelection == CourseTab.dates.rawValue { controllers.removeLast(1) controllers.append(contentsOf: [controllerUnit]) } else { From 82c1648d6735dab71bd84c18d215fc33663114fd Mon Sep 17 00:00:00 2001 From: forgotvas Date: Thu, 28 Mar 2024 17:58:47 +0300 Subject: [PATCH 097/136] chore: removed useless comments --- OpenEdX/Managers/PipManager.swift | 83 ------------------------------- 1 file changed, 83 deletions(-) diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift index d25095766..b06e3d7cd 100644 --- a/OpenEdX/Managers/PipManager.swift +++ b/OpenEdX/Managers/PipManager.swift @@ -175,89 +175,6 @@ public class PipManager: PipManagerProtocol { ) } } - /* - let isCourseOpened = hostCourseContainerView?.rootView.courseID == courseDetails.courseID - - if !isCourseOpened { - showTabScreen(tab: .dashboard) - - if courseDetails.isEnrolled { - showCourseScreens( - courseID: courseDetails.courseID, - isActive: nil, - courseStart: courseDetails.courseStart, - courseEnd: courseDetails.courseEnd, - enrollmentStart: courseDetails.enrollmentStart, - enrollmentEnd: courseDetails.enrollmentEnd, - title: courseDetails.courseTitle - ) - } else { - showCourseDetais( - courseID: courseDetails.courseID, - title: courseDetails.courseTitle - ) - } - } - - switch link.type { - case .courseVideos, - .courseDates, - .discussions, - .courseHandout, - .courseAnnouncement, - .courseDashboard: - popToCourseContainerView(animated: false) - default: - break - } - - DispatchQueue.main.asyncAfter(deadline: .now() + (isCourseOpened ? 0 : 1)) { - switch link.type { - case .courseDashboard: - self.hostCourseContainerView?.rootView.viewModel.selection = CourseTab.course.rawValue - case .courseVideos: - self.hostCourseContainerView?.rootView.viewModel.selection = CourseTab.videos.rawValue - case .courseDates: - self.hostCourseContainerView?.rootView.viewModel.selection = CourseTab.dates.rawValue - case .discussions, .discussionTopic, .discussionPost, .discussionComment: - self.hostCourseContainerView?.rootView.viewModel.selection = CourseTab.discussion.rawValue - case .courseHandout, .courseAnnouncement: - self.hostCourseContainerView?.rootView.viewModel.selection = CourseTab.handounds.rawValue - default: - break - } - - completion() - } - - public func showCourseScreens( - courseID: String, - isActive: Bool?, - courseStart: Date?, - courseEnd: Date?, - enrollmentStart: Date?, - enrollmentEnd: Date?, - title: String - ) { - let vm = Container.shared.resolve( - CourseContainerViewModel.self, - arguments: isActive, - courseStart, - courseEnd, - enrollmentStart, - enrollmentEnd - )! - let screensView = CourseContainerView( - viewModel: vm, - courseID: courseID, - title: title - ) - - let controller = UIHostingController(rootView: screensView) - navigationController.pushViewController(controller, animated: true) - } - - */ private var topCourseUnitController: UIHostingController? { router.getNavigationController().visibleViewController as? UIHostingController From 20d781e138ed7a19dd37c5ac91c884b6fe0a06e8 Mon Sep 17 00:00:00 2001 From: Eugene Yatsenko Date: Thu, 28 Mar 2024 16:20:21 +0100 Subject: [PATCH 098/136] chore: disable button if discussion blacked out --- .../Comments/Responses/ResponsesView.swift | 28 +++--- .../Comments/Thread/ThreadView.swift | 28 +++--- .../Presentation/Posts/PostsView.swift | 92 +++++++++---------- 3 files changed, 73 insertions(+), 75 deletions(-) diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index ccd470e1b..d1fc98fd4 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -157,22 +157,22 @@ public struct ResponsesView: View { .frameLimit(width: proxy.size.width) } - if !(parentComment.closed || viewModel.isBlackedOut) { - FlexibleKeyboardInputView( - hint: DiscussionLocalization.Response.addComment, - sendText: { commentText in - if let threadID = viewModel.postComments?.threadID { - Task { - await viewModel.postComment( - threadID: threadID, - rawBody: commentText, - parentID: commentID - ) - } + FlexibleKeyboardInputView( + hint: DiscussionLocalization.Response.addComment, + sendText: { commentText in + if let threadID = viewModel.postComments?.threadID { + Task { + await viewModel.postComment( + threadID: threadID, + rawBody: commentText, + parentID: commentID + ) } } - ).ignoresSafeArea(.all, edges: .horizontal) - } + } + ) + .ignoresSafeArea(.all, edges: .horizontal) + .disabled(parentComment.closed || viewModel.isBlackedOut) } } .onReceive(viewModel.addPostSubject, perform: { newComment in diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index 051fd3cc5..299f00fb2 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -154,22 +154,22 @@ public struct ThreadView: View { } .frameLimit(width: proxy.size.width) } - if !(thread.closed || viewModel.isBlackedOut) { - FlexibleKeyboardInputView( - hint: DiscussionLocalization.Thread.addResponse, - sendText: { commentText in - if let threadID = viewModel.postComments?.threadID { - Task { - await viewModel.postComment( - threadID: threadID, - rawBody: commentText, - parentID: viewModel.postComments?.parentID - ) - } + FlexibleKeyboardInputView( + hint: DiscussionLocalization.Thread.addResponse, + sendText: { commentText in + if let threadID = viewModel.postComments?.threadID { + Task { + await viewModel.postComment( + threadID: threadID, + rawBody: commentText, + parentID: viewModel.postComments?.parentID + ) } } - ).ignoresSafeArea(.all, edges: .horizontal) - } + } + ) + .ignoresSafeArea(.all, edges: .horizontal) + .disabled(thread.closed || viewModel.isBlackedOut) } .onReceive(viewModel.addPostSubject, perform: { newComment in guard let newComment else { return } diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index d668e3977..c8feb7cdc 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -119,31 +119,30 @@ public struct PostsView: View { .font(Theme.Fonts.titleLarge) .foregroundColor(Theme.Colors.textPrimary) Spacer() - if !(viewModel.isBlackedOut ?? false) { - Button(action: { - router.createNewThread( - courseID: courseID, - selectedTopic: currentBlockID, - onPostCreated: { - reloadPage(onSuccess: { - withAnimation { - scroll.scrollTo(1) - } - }) + Button(action: { + router.createNewThread( + courseID: courseID, + selectedTopic: currentBlockID, + onPostCreated: { + reloadPage(onSuccess: { + withAnimation { + scroll.scrollTo(1) + } }) - }, label: { - VStack { - CoreAssets.addComment.swiftUIImage - .font(Theme.Fonts.labelLarge) - .padding(6) - } - .foregroundColor(Theme.Colors.white) - .background( - Circle() - .foregroundColor(Theme.Colors.accentButtonColor) - ) - }) - } + }) + }, label: { + VStack { + CoreAssets.addComment.swiftUIImage + .font(Theme.Fonts.labelLarge) + .padding(6) + } + .foregroundColor(Theme.Colors.white) + .background( + Circle() + .foregroundColor(Theme.Colors.accentButtonColor) + ) + }) + .disabled(viewModel.isBlackedOut ?? false) } .padding(.horizontal, 24) @@ -177,30 +176,29 @@ public struct PostsView: View { .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .padding(.top, 40) - if !(viewModel.isBlackedOut ?? false) { - Text(DiscussionLocalization.Posts.NoDiscussion.description) - .font(Theme.Fonts.bodyLarge) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .padding(.top, 12) - StyledButton( - DiscussionLocalization.Posts.NoDiscussion.addPost, - action: { - router.createNewThread(courseID: courseID, - selectedTopic: currentBlockID, - onPostCreated: { - reloadPage(onSuccess: { - withAnimation { - scroll.scrollTo(1) - } - }) + Text(DiscussionLocalization.Posts.NoDiscussion.description) + .font(Theme.Fonts.bodyLarge) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 12) + StyledButton( + DiscussionLocalization.Posts.NoDiscussion.addPost, + action: { + router.createNewThread(courseID: courseID, + selectedTopic: currentBlockID, + onPostCreated: { + reloadPage(onSuccess: { + withAnimation { + scroll.scrollTo(1) + } }) - }, - isTransparent: true) - .frame(width: 215) - .padding(.top, 40) - .colorMultiply(Theme.Colors.accentColor) - } + }) + }, + isTransparent: true) + .frame(width: 215) + .padding(.top, 40) + .colorMultiply(Theme.Colors.accentColor) + .disabled(viewModel.isBlackedOut ?? false) } .padding(24) .padding(.top, 100) From c329f5396c93bc88a915295bd4ab94614a557c2a Mon Sep 17 00:00:00 2001 From: forgotvas Date: Thu, 28 Mar 2024 18:45:32 +0300 Subject: [PATCH 099/136] chore: refactor --- .../Unit/CourseUnitViewModel.swift | 7 +- OpenEdX/Managers/PipManager.swift | 47 ++++-------- OpenEdX/Router.swift | 75 ++++++++++++++----- 3 files changed, 73 insertions(+), 56 deletions(-) diff --git a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift index ca18d3250..8c05ae6cf 100644 --- a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift +++ b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift @@ -302,12 +302,7 @@ public class CourseUnitViewModel: ObservableObject { func route(to data: VerticalData?, animated: Bool = false) { guard let data = data else { return } - if data.verticalIndex == verticalIndex, - let block = blockFor(index: data.blockIndex, in: verticals[verticalIndex]) { - // if we are on same vertical now - lessonID = block.blockId - loadIndex() - } else if let vertical = vertical(for: data), + if let vertical = vertical(for: data), let block = blockFor(index: data.blockIndex, in: vertical) { router.replaceCourseUnit( courseName: courseName, diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift index b06e3d7cd..17e572102 100644 --- a/OpenEdX/Managers/PipManager.swift +++ b/OpenEdX/Managers/PipManager.swift @@ -73,8 +73,7 @@ public class PipManager: PipManagerProtocol { return } } - - + // else create navigation stack and push new stack to root navigation controller try await navigate(to: holder) } @@ -114,22 +113,15 @@ public class PipManager: PipManagerProtocol { for (sequentialIndex, sequential) in chapter.childs.enumerated() { for (verticalIndex, vertical) in sequential.childs.enumerated() { for (_, block) in vertical.childs.enumerated() where block.id == holder.blockID { - let viewModel = Container.shared.resolve( - CourseUnitViewModel.self, - arguments: block.blockId, - courseStructure.id, - courseStructure.displayName, - courseStructure.childs, - chapterIndex, - sequentialIndex, - verticalIndex - )! - - let config = Container.shared.resolve(ConfigProtocol.self) - let isDropdownActive = config?.uiComponents.courseNestedListEnabled ?? false - - let view = CourseUnitView(viewModel: viewModel, isDropdownActive: isDropdownActive) - return UIHostingController(rootView: view) + return router.getUnitController( + courseName: courseStructure.displayName, + blockId: block.blockId, + courseID: courseStructure.id, + verticalIndex: verticalIndex, + chapters: courseStructure.childs, + chapterIndex: chapterIndex, + sequentialIndex: verticalIndex + ) } } } @@ -144,22 +136,15 @@ public class PipManager: PipManagerProtocol { ) async throws -> UIHostingController { let courseDetails = try await getCourseDetails(for: holder) let isActive: Bool? = nil - - let vm = Container.shared.resolve( - CourseContainerViewModel.self, - arguments: isActive, - courseDetails.courseStart, - courseDetails.courseEnd, - courseDetails.enrollmentStart, - courseDetails.enrollmentEnd - )! - let screensView = CourseContainerView( - viewModel: vm, + let controller = getCourseScreensController( courseID: courseDetails.courseID, + isActive: isActive, + courseStart: courseDetails.courseStart, + courseEnd: courseDetails.courseEnd, + enrollmentStart: courseDetails.enrollmentStart, + enrollmentEnd: courseDetails.enrollmentEnd, title: courseDetails.courseTitle ) - - let controller = UIHostingController(rootView: screensView) controller.rootView.viewModel.selection = holder.selectedCourseTab return controller } diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 009cd19d6..36306feb1 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -340,6 +340,27 @@ public class Router: AuthorizationRouter, enrollmentEnd: Date?, title: String ) { + let controller = getCourseScreensController( + courseID: courseID, + isActive: isActive, + courseStart: courseStart, + courseEnd: courseEnd, + enrollmentStart: enrollmentStart, + enrollmentEnd: enrollmentEnd, + title: title + ) + navigationController.pushViewController(controller, animated: true) + } + + public func getCourseScreensController( + courseID: String, + isActive: Bool?, + courseStart: Date?, + courseEnd: Date?, + enrollmentStart: Date?, + enrollmentEnd: Date?, + title: String + ) -> UIHostingController { let vm = Container.shared.resolve( CourseContainerViewModel.self, arguments: isActive, @@ -354,8 +375,7 @@ public class Router: AuthorizationRouter, title: title ) - let controller = UIHostingController(rootView: screensView) - navigationController.pushViewController(controller, animated: true) + return UIHostingController(rootView: screensView) } public func showHandoutsUpdatesView( @@ -383,6 +403,27 @@ public class Router: AuthorizationRouter, chapterIndex: Int, sequentialIndex: Int ) { + let controller = getUnitController( + courseName: courseName, + blockId: blockId, + courseID: courseID, + verticalIndex: verticalIndex, + chapters: chapters, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex + ) + navigationController.pushViewController(controller, animated: true) + } + + public func getUnitController( + courseName: String, + blockId: String, + courseID: String, + verticalIndex: Int, + chapters: [CourseChapter], + chapterIndex: Int, + sequentialIndex: Int + ) -> UIHostingController { let viewModel = Container.shared.resolve( CourseUnitViewModel.self, arguments: blockId, @@ -398,8 +439,7 @@ public class Router: AuthorizationRouter, let isDropdownActive = config?.uiComponents.courseNestedListEnabled ?? false let view = CourseUnitView(viewModel: viewModel, isDropdownActive: isDropdownActive) - let controller = UIHostingController(rootView: view) - navigationController.pushViewController(controller, animated: true) + return UIHostingController(rootView: view) } public func showCourseComponent( @@ -463,24 +503,21 @@ public class Router: AuthorizationRouter, viewModel: vmVertical ) let controllerVertical = UIHostingController(rootView: viewVertical) - - let viewModel = Container.shared.resolve( - CourseUnitViewModel.self, - arguments: blockId, - courseID, - courseName, - chapters, - chapterIndex, - sequentialIndex, - verticalIndex - )! + let controllerUnit = getUnitController( + courseName: courseName, + blockId: blockId, + courseID: courseID, + verticalIndex: verticalIndex, + chapters: chapters, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex + ) + + let config = Container.shared.resolve(ConfigProtocol.self) - let isDropdownActive = config?.uiComponents.courseNestedListEnabled ?? false let isCourseNestedListEnabled = config?.uiComponents.courseNestedListEnabled ?? false - - let view = CourseUnitView(viewModel: viewModel, isDropdownActive: isDropdownActive) - let controllerUnit = UIHostingController(rootView: view) + var controllers = navigationController.viewControllers if isCourseNestedListEnabled || currentCourseTabSelection == CourseTab.dates.rawValue { From 42e539f7a64c7301f01f8ffb7ca3b1d2bef2d6ce Mon Sep 17 00:00:00 2001 From: forgotvas Date: Thu, 28 Mar 2024 18:46:35 +0300 Subject: [PATCH 100/136] chore: compilation error --- OpenEdX/Managers/PipManager.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift index 17e572102..ea1d6539d 100644 --- a/OpenEdX/Managers/PipManager.swift +++ b/OpenEdX/Managers/PipManager.swift @@ -136,7 +136,7 @@ public class PipManager: PipManagerProtocol { ) async throws -> UIHostingController { let courseDetails = try await getCourseDetails(for: holder) let isActive: Bool? = nil - let controller = getCourseScreensController( + let controller = router.getCourseScreensController( courseID: courseDetails.courseID, isActive: isActive, courseStart: courseDetails.courseStart, From 5c8799fe5785fe25175dfd6651aec09244535ebe Mon Sep 17 00:00:00 2001 From: forgotvas Date: Thu, 28 Mar 2024 19:02:25 +0300 Subject: [PATCH 101/136] chore: fix navigation --- .../Unit/CourseUnitViewModel.swift | 20 +++++++++++-------- OpenEdX/Managers/PipManager.swift | 2 +- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift index 8c05ae6cf..b0667767b 100644 --- a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift +++ b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift @@ -61,7 +61,7 @@ public class CourseUnitViewModel: ObservableObject { case previous } - struct VerticalData { + struct VerticalData: Equatable { var chapterIndex: Int var sequentialIndex: Int var verticalIndex: Int @@ -220,12 +220,16 @@ public class CourseUnitViewModel: ObservableObject { // MARK: Navigation to next vertical var nextData: VerticalData? { nextData( - from: VerticalData( - chapterIndex: chapterIndex, - sequentialIndex: sequentialIndex, - verticalIndex: verticalIndex, - blockIndex: 0 - ) + from: currentData + ) + } + + var currentData: VerticalData { + VerticalData( + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex, + verticalIndex: verticalIndex, + blockIndex: index ) } @@ -301,7 +305,7 @@ public class CourseUnitViewModel: ObservableObject { } func route(to data: VerticalData?, animated: Bool = false) { - guard let data = data else { return } + guard let data = data, data != currentData else { return } if let vertical = vertical(for: data), let block = blockFor(index: data.blockIndex, in: vertical) { router.replaceCourseUnit( diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift index ea1d6539d..c3cf6b55e 100644 --- a/OpenEdX/Managers/PipManager.swift +++ b/OpenEdX/Managers/PipManager.swift @@ -120,7 +120,7 @@ public class PipManager: PipManagerProtocol { verticalIndex: verticalIndex, chapters: courseStructure.childs, chapterIndex: chapterIndex, - sequentialIndex: verticalIndex + sequentialIndex: sequentialIndex ) } } From b0a1d0ec15398d2da5ec41b4d2f7f2902f506cd6 Mon Sep 17 00:00:00 2001 From: forgotvas Date: Thu, 28 Mar 2024 19:07:23 +0300 Subject: [PATCH 102/136] chore: removed logs --- .../Video/EncodedVideoPlayerViewModel.swift | 2 -- .../Presentation/Video/PlayerViewController.swift | 2 -- .../Video/PlayerViewControllerHolder.swift | 14 +------------- OpenEdX/Managers/PipManager.swift | 1 - 4 files changed, 1 insertion(+), 18 deletions(-) diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift index 5f4121c2c..2c7689a16 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift @@ -41,10 +41,8 @@ public class EncodedVideoPlayerViewModel: VideoPlayerViewModel { courseID: courseID, selectedCourseTab: selectedCourseTab ) { - print("ALARM restore holder") controllerHolder = holder } else { - print("ALARM create holder") let holder = PlayerViewControllerHolder( url: url, blockID: blockID, diff --git a/Course/Course/Presentation/Video/PlayerViewController.swift b/Course/Course/Presentation/Video/PlayerViewController.swift index 92e30b662..4671663fb 100644 --- a/Course/Course/Presentation/Video/PlayerViewController.swift +++ b/Course/Course/Presentation/Video/PlayerViewController.swift @@ -37,7 +37,6 @@ struct PlayerViewController: UIViewControllerRepresentable { return playerHolder.playerController } - print("ALARM create new player") let controller = playerHolder.playerController controller.modalPresentationStyle = .fullScreen controller.allowsPictureInPicturePlayback = true @@ -61,7 +60,6 @@ struct PlayerViewController: UIViewControllerRepresentable { func updateUIViewController(_ playerController: AVPlayerViewController, context: Context) { let asset = playerController.player?.currentItem?.asset as? AVURLAsset if asset?.url.absoluteString != videoURL?.absoluteString && !playerHolder.isPipModeActive { - print("ALARM replace player") let player = context.coordinator.player(from: playerController) player?.replaceCurrentItem(with: AVPlayerItem(url: videoURL!)) player?.currentItem?.preferredMaximumResolution = videoResolution diff --git a/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift b/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift index c724995ca..2a1cde020 100644 --- a/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift +++ b/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift @@ -63,38 +63,26 @@ public class PlayerViewControllerHolder: NSObject, AVPlayerViewControllerDelegat Container.shared.resolve(PipManagerProtocol.self)?.set(holder: self) } -// func playerViewControllerDidStartPictureInPicture(_ playerViewController: AVPlayerViewController) { -// -// } - public func playerViewController( _ playerViewController: AVPlayerViewController, failedToStartPictureInPictureWithError error: any Error ) { isPipModeActive = false Container.shared.resolve(PipManagerProtocol.self)?.remove(holder: self) - print("ALARM failed to start \(error)") } public func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) { isPipModeActive = false Container.shared.resolve(PipManagerProtocol.self)?.remove(holder: self) - print("ALARM did stop picture in picture") } - -// func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) { -// -// } + public func playerViewControllerRestoreUserInterfaceForPictureInPictureStop( _ playerViewController: AVPlayerViewController ) async -> Bool { - print("ALARM restore controller") do { try await Container.shared.resolve(PipManagerProtocol.self)?.restore(holder: self) - print("ALARM restore completed") return true } catch { - print("ALARM restore failed") return false } } diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift index c3cf6b55e..db108c577 100644 --- a/OpenEdX/Managers/PipManager.swift +++ b/OpenEdX/Managers/PipManager.swift @@ -35,7 +35,6 @@ public class PipManager: PipManagerProtocol { courseID: String, selectedCourseTab: Int ) -> PlayerViewControllerHolder? { - print("ALARM navigationStack: \(router.getNavigationController().children)") if controllerHolder?.blockID == blockID, controllerHolder?.courseID == courseID, controllerHolder?.selectedCourseTab == selectedCourseTab { From 80d3e60b1992c2e4722f01228591fc969c937422 Mon Sep 17 00:00:00 2001 From: forgotvas Date: Thu, 28 Mar 2024 19:18:46 +0300 Subject: [PATCH 103/136] chore: warnings --- OpenEdX/DI/ScreenAssembly.swift | 2 +- OpenEdX/Managers/PipManager.swift | 43 +++++-------------------------- OpenEdX/Router.swift | 3 +-- 3 files changed, 9 insertions(+), 39 deletions(-) diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 7a17c656d..db8829006 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -186,7 +186,7 @@ class ScreenAssembly: Assembly { } container.register(ProfileViewModel.self) { r in ProfileViewModel( - interactor: r.resolve(ProfileInteractorProtocol.self)!, + interactor: r.resolve(ProfileInteractorProtocol.self)!, downloadManager: r.resolve(DownloadManagerProtocol.self)!, router: r.resolve(ProfileRouter.self)!, analytics: r.resolve(ProfileAnalytics.self)!, diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift index db108c577..c91f9b59d 100644 --- a/OpenEdX/Managers/PipManager.swift +++ b/OpenEdX/Managers/PipManager.swift @@ -5,13 +5,9 @@ // Created by Vadim Kuznetsov on 20.03.24. // -import Combine import Course import Discovery -import Foundation import SwiftUI -import Swinject -import Core public class PipManager: PipManagerProtocol { var controllerHolder: PlayerViewControllerHolder? @@ -77,7 +73,7 @@ public class PipManager: PipManagerProtocol { } @MainActor - func navigate(to holder: PlayerViewControllerHolder) async throws { + private func navigate(to holder: PlayerViewControllerHolder) async throws { let currentControllers = router.getNavigationController().viewControllers guard let mainController = currentControllers.first as? UIHostingController else { return @@ -99,8 +95,9 @@ public class PipManager: PipManagerProtocol { router.getNavigationController().setViewControllers(viewControllers, animated: true) } - - @MainActor func courseUnitController( + + @MainActor + private func courseUnitController( for holder: PlayerViewControllerHolder ) async throws -> UIHostingController { @@ -111,7 +108,7 @@ public class PipManager: PipManagerProtocol { for (chapterIndex, chapter) in courseStructure.childs.enumerated() { for (sequentialIndex, sequential) in chapter.childs.enumerated() { for (verticalIndex, vertical) in sequential.childs.enumerated() { - for (_, block) in vertical.childs.enumerated() where block.id == holder.blockID { + for block in vertical.childs where block.id == holder.blockID { return router.getUnitController( courseName: courseStructure.displayName, blockId: block.blockId, @@ -130,7 +127,7 @@ public class PipManager: PipManagerProtocol { } @MainActor - func containerController( + private func containerController( for holder: PlayerViewControllerHolder ) async throws -> UIHostingController { let courseDetails = try await getCourseDetails(for: holder) @@ -148,7 +145,7 @@ public class PipManager: PipManagerProtocol { return controller } - func getCourseDetails(for holder: PlayerViewControllerHolder) async throws -> CourseDetails { + private func getCourseDetails(for holder: PlayerViewControllerHolder) async throws -> CourseDetails { if let value = try? await discoveryInteractor.getLoadedCourseDetails( courseID: holder.courseID ) { @@ -167,32 +164,6 @@ public class PipManager: PipManagerProtocol { extension PipManager { enum PipManagerError: Error { - case cantGetCourseDetails case cantCreateCourseUnitView } } - -extension UIViewController { - public var mostTopController: UIViewController? { - topController(from: self) - } - - private func topController(from controller: UIViewController?) -> UIViewController? { - if let navigationController = controller as? UINavigationController { - return topController(from: navigationController.visibleViewController) - } else if let tabBarController = controller as? UITabBarController { - return topController(from: tabBarController.selectedViewController) - } else if let splitController = controller as? UISplitViewController { - return topController(from: splitController.viewControllers.last) - } else { - if let presentedController = controller?.presentedViewController { - return topController(from: presentedController) - } else { - if let child = controller?.children.last { - return topController(from: child) - } - return controller - } - } - } -} diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 36306feb1..a07db6df8 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -513,8 +513,7 @@ public class Router: AuthorizationRouter, chapterIndex: chapterIndex, sequentialIndex: sequentialIndex ) - - + let config = Container.shared.resolve(ConfigProtocol.self) let isCourseNestedListEnabled = config?.uiComponents.courseNestedListEnabled ?? false From 05c7fcde898c57f7256201a8c0bb9dd7e4115e43 Mon Sep 17 00:00:00 2001 From: forgotvas Date: Thu, 28 Mar 2024 19:19:34 +0300 Subject: [PATCH 104/136] chore: refactor --- OpenEdX/Managers/PipManager.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift index c91f9b59d..bc21855d1 100644 --- a/OpenEdX/Managers/PipManager.swift +++ b/OpenEdX/Managers/PipManager.swift @@ -11,7 +11,6 @@ import SwiftUI public class PipManager: PipManagerProtocol { var controllerHolder: PlayerViewControllerHolder? - private var restorationTask: Task? let discoveryInteractor: DiscoveryInteractorProtocol let courseInteractor: CourseInteractorProtocol let router: Router @@ -42,15 +41,11 @@ public class PipManager: PipManagerProtocol { public func set(holder: PlayerViewControllerHolder) { controllerHolder = holder - restorationTask?.cancel() - restorationTask = nil } public func remove(holder: PlayerViewControllerHolder) { if controllerHolder == holder { controllerHolder = nil - restorationTask?.cancel() - restorationTask = nil } } From 16ef27588225e2a4a96407de119be729a8f07d3d Mon Sep 17 00:00:00 2001 From: forgotvas Date: Thu, 28 Mar 2024 19:25:44 +0300 Subject: [PATCH 105/136] chore: refactor --- OpenEdX/Router.swift | 46 ++++++++++++++++++++++++++++---------------- 1 file changed, 29 insertions(+), 17 deletions(-) diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index a07db6df8..d4a212ee0 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -314,6 +314,25 @@ public class Router: AuthorizationRouter, chapterIndex: Int, sequentialIndex: Int ) { + let controller = getVerticalController( + courseID: courseID, + courseName: courseName, + title: title, + chapters: chapters, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex + ) + navigationController.pushViewController(controller, animated: true) + } + + public func getVerticalController( + courseID: String, + courseName: String, + title: String, + chapters: [CourseChapter], + chapterIndex: Int, + sequentialIndex: Int + ) -> UIHostingController { let viewModel = Container.shared.resolve( CourseVerticalViewModel.self, arguments: chapters, @@ -327,8 +346,7 @@ public class Router: AuthorizationRouter, courseID: courseID, viewModel: viewModel ) - let controller = UIHostingController(rootView: view) - navigationController.pushViewController(controller, animated: true) + return UIHostingController(rootView: view) } public func showCourseScreens( @@ -488,21 +506,6 @@ public class Router: AuthorizationRouter, sequentialIndex: Int, animated: Bool ) { - - let vmVertical = Container.shared.resolve( - CourseVerticalViewModel.self, - arguments: chapters, - chapterIndex, - sequentialIndex - )! - - let viewVertical = CourseVerticalView( - title: chapters[chapterIndex].childs[sequentialIndex].displayName, - courseName: courseName, - courseID: courseID, - viewModel: vmVertical - ) - let controllerVertical = UIHostingController(rootView: viewVertical) let controllerUnit = getUnitController( courseName: courseName, @@ -523,6 +526,15 @@ public class Router: AuthorizationRouter, controllers.removeLast(1) controllers.append(contentsOf: [controllerUnit]) } else { + let controllerVertical = getVerticalController( + courseID: courseID, + courseName: courseName, + title: chapters[chapterIndex].childs[sequentialIndex].displayName, + chapters: chapters, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex + ) + controllers.removeLast(2) controllers.append(contentsOf: [controllerVertical, controllerUnit]) } From a9c856eebab5e3680dbf5ea7e6054254f650eef5 Mon Sep 17 00:00:00 2001 From: forgotvas Date: Thu, 28 Mar 2024 19:48:06 +0300 Subject: [PATCH 106/136] chore: fallback for openEdX --- OpenEdX/DI/AppAssembly.swift | 3 ++- OpenEdX/Managers/PipManager.swift | 38 ++++++++++++++++++++++++++++++- 2 files changed, 39 insertions(+), 2 deletions(-) diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 54a667cdb..295b9ae1c 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -202,7 +202,8 @@ class AppAssembly: Assembly { PipManager( router: r.resolve(Router.self)!, discoveryInteractor: r.resolve(DiscoveryInteractorProtocol.self)!, - courseInteractor: r.resolve(CourseInteractorProtocol.self)! + courseInteractor: r.resolve(CourseInteractorProtocol.self)!, + isNestedListEnabled: r.resolve(ConfigProtocol.self)?.uiComponents.courseNestedListEnabled ?? false ) }.inObjectScope(.container) } diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift index bc21855d1..1c68b1ed6 100644 --- a/OpenEdX/Managers/PipManager.swift +++ b/OpenEdX/Managers/PipManager.swift @@ -14,14 +14,17 @@ public class PipManager: PipManagerProtocol { let discoveryInteractor: DiscoveryInteractorProtocol let courseInteractor: CourseInteractorProtocol let router: Router + let isNestedListEnabled: Bool public init( router: Router, discoveryInteractor: DiscoveryInteractorProtocol, - courseInteractor: CourseInteractorProtocol + courseInteractor: CourseInteractorProtocol, + isNestedListEnabled: Bool ) { self.discoveryInteractor = discoveryInteractor self.courseInteractor = courseInteractor self.router = router + self.isNestedListEnabled = isNestedListEnabled } public func holder( @@ -86,11 +89,43 @@ public class PipManager: PipManagerProtocol { viewControllers.append(try await containerController(for: holder)) } + if !isNestedListEnabled && holder.selectedCourseTab != CourseTab.dates.rawValue { + viewControllers.append(try await courseVerticalController(for: holder)) + } + viewControllers.append(try await courseUnitController(for: holder)) router.getNavigationController().setViewControllers(viewControllers, animated: true) } + @MainActor + private func courseVerticalController( + for holder: PlayerViewControllerHolder + ) async throws -> UIHostingController { + var courseStructure = try await courseInteractor.getLoadedCourseBlocks(courseID: holder.courseID) + if holder.selectedCourseTab == CourseTab.videos.rawValue { + courseStructure = courseInteractor.getCourseVideoBlocks(fullStructure: courseStructure) + } + for (chapterIndex, chapter) in courseStructure.childs.enumerated() { + for (sequentialIndex, sequential) in chapter.childs.enumerated() { + for (verticalIndex, vertical) in sequential.childs.enumerated() { + for block in vertical.childs where block.id == holder.blockID { + return router.getVerticalController( + courseID: holder.courseID, + courseName: courseStructure.displayName, + title: courseStructure.childs[chapterIndex].childs[sequentialIndex].displayName, + chapters: courseStructure.childs, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex + ) + } + } + } + } + + throw PipManagerError.cantCreateCourseVerticalView + } + @MainActor private func courseUnitController( for holder: PlayerViewControllerHolder @@ -160,5 +195,6 @@ public class PipManager: PipManagerProtocol { extension PipManager { enum PipManagerError: Error { case cantCreateCourseUnitView + case cantCreateCourseVerticalView } } From 43056e00edca91c16c4ebe0767c80abd09b4bdd2 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Fri, 29 Mar 2024 10:53:50 +0200 Subject: [PATCH 107/136] feat: Text and icons adjustment project (#364) * feat: text and icons adjustment project * fix: colors in AlertView.swift * chore: resolve PR comments --- .../leaveProfile.imageset/Contents.json | 4 +- .../leaveProfile.imageset/Frame 273-2.svg | 19 ------- .../leaveProfile.imageset/Frame 273.svg | 19 ------- .../leaveProfileDark.svg | 3 + .../leaveProfileLight.svg | 3 + .../Profile/logOut.imageset/Contents.json | 4 +- .../_93_Door_Exit_Logout_Out-2.svg | 14 ----- .../_93_Door_Exit_Logout_Out.svg | 14 ----- .../logOut.imageset/logOut_darktheme.svg | 3 + .../logOut.imageset/logOut_lighttheme.svg | 3 + .../checkEmail.imageset/Contents.json | 4 +- .../checkEmail.imageset/_1-2.svg | 18 ------ .../checkEmail.imageset/_1.svg | 18 ------ .../checkEmail.imageset/checkEmailDark.svg | 3 + .../checkEmail.imageset/checkEmailLight.svg | 3 + .../noWifi.imageset/Contents.json | 12 ++++ .../noWifi.imageset/noWifi.svg | 3 + .../noWifiMini.imageset/Contents.json | 12 ++++ .../noWifiMini.imageset/noWifi.svg | 14 +++++ .../notAvaliable.imageset/Contents.json | 4 +- .../{Frame 273-3.svg => notAvaliableDark.svg} | 13 ++--- ...{Frame 273-4.svg => notAvaliableLight.svg} | 13 ++--- Core/Core/Extensions/ViewExtension.swift | 3 +- Core/Core/SwiftGen/Assets.swift | 2 + Core/Core/SwiftGen/Strings.swift | 18 ++++-- Core/Core/View/Base/AlertView.swift | 6 +- Core/Core/en.lproj/Localizable.strings | 10 ++-- Core/Core/uk.lproj/Localizable.strings | 8 ++- .../Unit/CourseNavigationView.swift | 3 +- .../Presentation/Unit/CourseUnitView.swift | 45 +++++++-------- .../Unit/Subviews/UnknownView.swift | 2 + Course/Course/SwiftGen/Strings.swift | 24 ++++---- Course/Course/en.lproj/Localizable.strings | 14 ++--- .../Presentation/DashboardView.swift | 25 ++++----- Dashboard/Dashboard/SwiftGen/Strings.swift | 2 - .../Dashboard/en.lproj/Localizable.strings | 3 +- .../NativeDiscovery/CourseDetailsView.swift | 56 ++++++++++++------- Discovery/Discovery/SwiftGen/Strings.swift | 2 + .../Discovery/en.lproj/Localizable.strings | 3 +- .../Discovery/uk.lproj/Localizable.strings | 3 +- OpenEdX.xcodeproj/project.pbxproj | 48 ++++++++-------- .../EditProfile/EditProfileView.swift | 1 + Profile/Profile/SwiftGen/Strings.swift | 12 ++-- Profile/Profile/en.lproj/Localizable.strings | 8 +-- .../Colors/warningText.colorset/Contents.json | 38 +++++++++++++ Theme/Theme/SwiftGen/ThemeAssets.swift | 1 + Theme/Theme/Theme.swift | 1 + 47 files changed, 278 insertions(+), 263 deletions(-) delete mode 100644 Core/Core/Assets.xcassets/Profile/leaveProfile.imageset/Frame 273-2.svg delete mode 100644 Core/Core/Assets.xcassets/Profile/leaveProfile.imageset/Frame 273.svg create mode 100644 Core/Core/Assets.xcassets/Profile/leaveProfile.imageset/leaveProfileDark.svg create mode 100644 Core/Core/Assets.xcassets/Profile/leaveProfile.imageset/leaveProfileLight.svg delete mode 100644 Core/Core/Assets.xcassets/Profile/logOut.imageset/_93_Door_Exit_Logout_Out-2.svg delete mode 100644 Core/Core/Assets.xcassets/Profile/logOut.imageset/_93_Door_Exit_Logout_Out.svg create mode 100644 Core/Core/Assets.xcassets/Profile/logOut.imageset/logOut_darktheme.svg create mode 100644 Core/Core/Assets.xcassets/Profile/logOut.imageset/logOut_lighttheme.svg delete mode 100644 Core/Core/Assets.xcassets/checkEmail.imageset/_1-2.svg delete mode 100644 Core/Core/Assets.xcassets/checkEmail.imageset/_1.svg create mode 100644 Core/Core/Assets.xcassets/checkEmail.imageset/checkEmailDark.svg create mode 100644 Core/Core/Assets.xcassets/checkEmail.imageset/checkEmailLight.svg create mode 100644 Core/Core/Assets.xcassets/noWifi.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/noWifi.imageset/noWifi.svg create mode 100644 Core/Core/Assets.xcassets/noWifiMini.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/noWifiMini.imageset/noWifi.svg rename Core/Core/Assets.xcassets/notAvaliable.imageset/{Frame 273-3.svg => notAvaliableDark.svg} (55%) rename Core/Core/Assets.xcassets/notAvaliable.imageset/{Frame 273-4.svg => notAvaliableLight.svg} (52%) create mode 100644 Theme/Theme/Assets.xcassets/Colors/warningText.colorset/Contents.json diff --git a/Core/Core/Assets.xcassets/Profile/leaveProfile.imageset/Contents.json b/Core/Core/Assets.xcassets/Profile/leaveProfile.imageset/Contents.json index f9973c21e..cb121759a 100644 --- a/Core/Core/Assets.xcassets/Profile/leaveProfile.imageset/Contents.json +++ b/Core/Core/Assets.xcassets/Profile/leaveProfile.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Frame 273-2.svg", + "filename" : "leaveProfileLight.svg", "idiom" : "universal" }, { @@ -11,7 +11,7 @@ "value" : "dark" } ], - "filename" : "Frame 273.svg", + "filename" : "leaveProfileDark.svg", "idiom" : "universal" } ], diff --git a/Core/Core/Assets.xcassets/Profile/leaveProfile.imageset/Frame 273-2.svg b/Core/Core/Assets.xcassets/Profile/leaveProfile.imageset/Frame 273-2.svg deleted file mode 100644 index 539abbf57..000000000 --- a/Core/Core/Assets.xcassets/Profile/leaveProfile.imageset/Frame 273-2.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/Core/Core/Assets.xcassets/Profile/leaveProfile.imageset/Frame 273.svg b/Core/Core/Assets.xcassets/Profile/leaveProfile.imageset/Frame 273.svg deleted file mode 100644 index 439f7839d..000000000 --- a/Core/Core/Assets.xcassets/Profile/leaveProfile.imageset/Frame 273.svg +++ /dev/null @@ -1,19 +0,0 @@ - - - - - - - - - - - - - - - - - - - diff --git a/Core/Core/Assets.xcassets/Profile/leaveProfile.imageset/leaveProfileDark.svg b/Core/Core/Assets.xcassets/Profile/leaveProfile.imageset/leaveProfileDark.svg new file mode 100644 index 000000000..f53f2ce8f --- /dev/null +++ b/Core/Core/Assets.xcassets/Profile/leaveProfile.imageset/leaveProfileDark.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/Profile/leaveProfile.imageset/leaveProfileLight.svg b/Core/Core/Assets.xcassets/Profile/leaveProfile.imageset/leaveProfileLight.svg new file mode 100644 index 000000000..bae97e99a --- /dev/null +++ b/Core/Core/Assets.xcassets/Profile/leaveProfile.imageset/leaveProfileLight.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/Profile/logOut.imageset/Contents.json b/Core/Core/Assets.xcassets/Profile/logOut.imageset/Contents.json index 5910a0fb6..ee3f397ac 100644 --- a/Core/Core/Assets.xcassets/Profile/logOut.imageset/Contents.json +++ b/Core/Core/Assets.xcassets/Profile/logOut.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "_93_Door_Exit_Logout_Out.svg", + "filename" : "logOut_lighttheme.svg", "idiom" : "universal" }, { @@ -11,7 +11,7 @@ "value" : "dark" } ], - "filename" : "_93_Door_Exit_Logout_Out-2.svg", + "filename" : "logOut_darktheme.svg", "idiom" : "universal" } ], diff --git a/Core/Core/Assets.xcassets/Profile/logOut.imageset/_93_Door_Exit_Logout_Out-2.svg b/Core/Core/Assets.xcassets/Profile/logOut.imageset/_93_Door_Exit_Logout_Out-2.svg deleted file mode 100644 index ca3bf3908..000000000 --- a/Core/Core/Assets.xcassets/Profile/logOut.imageset/_93_Door_Exit_Logout_Out-2.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/Core/Core/Assets.xcassets/Profile/logOut.imageset/_93_Door_Exit_Logout_Out.svg b/Core/Core/Assets.xcassets/Profile/logOut.imageset/_93_Door_Exit_Logout_Out.svg deleted file mode 100644 index 60b35eecb..000000000 --- a/Core/Core/Assets.xcassets/Profile/logOut.imageset/_93_Door_Exit_Logout_Out.svg +++ /dev/null @@ -1,14 +0,0 @@ - - - - - - - - - - - - - - diff --git a/Core/Core/Assets.xcassets/Profile/logOut.imageset/logOut_darktheme.svg b/Core/Core/Assets.xcassets/Profile/logOut.imageset/logOut_darktheme.svg new file mode 100644 index 000000000..a52ebb029 --- /dev/null +++ b/Core/Core/Assets.xcassets/Profile/logOut.imageset/logOut_darktheme.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/Profile/logOut.imageset/logOut_lighttheme.svg b/Core/Core/Assets.xcassets/Profile/logOut.imageset/logOut_lighttheme.svg new file mode 100644 index 000000000..3545c6727 --- /dev/null +++ b/Core/Core/Assets.xcassets/Profile/logOut.imageset/logOut_lighttheme.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/checkEmail.imageset/Contents.json b/Core/Core/Assets.xcassets/checkEmail.imageset/Contents.json index cf478a766..f00259a41 100644 --- a/Core/Core/Assets.xcassets/checkEmail.imageset/Contents.json +++ b/Core/Core/Assets.xcassets/checkEmail.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "_1.svg", + "filename" : "checkEmailLight.svg", "idiom" : "universal" }, { @@ -11,7 +11,7 @@ "value" : "dark" } ], - "filename" : "_1-2.svg", + "filename" : "checkEmailDark.svg", "idiom" : "universal" } ], diff --git a/Core/Core/Assets.xcassets/checkEmail.imageset/_1-2.svg b/Core/Core/Assets.xcassets/checkEmail.imageset/_1-2.svg deleted file mode 100644 index 8f70d7871..000000000 --- a/Core/Core/Assets.xcassets/checkEmail.imageset/_1-2.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/Core/Core/Assets.xcassets/checkEmail.imageset/_1.svg b/Core/Core/Assets.xcassets/checkEmail.imageset/_1.svg deleted file mode 100644 index c3b7babd2..000000000 --- a/Core/Core/Assets.xcassets/checkEmail.imageset/_1.svg +++ /dev/null @@ -1,18 +0,0 @@ - - - - - - - - - - - - - - - - - - diff --git a/Core/Core/Assets.xcassets/checkEmail.imageset/checkEmailDark.svg b/Core/Core/Assets.xcassets/checkEmail.imageset/checkEmailDark.svg new file mode 100644 index 000000000..552d0c036 --- /dev/null +++ b/Core/Core/Assets.xcassets/checkEmail.imageset/checkEmailDark.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/checkEmail.imageset/checkEmailLight.svg b/Core/Core/Assets.xcassets/checkEmail.imageset/checkEmailLight.svg new file mode 100644 index 000000000..47f8ceac3 --- /dev/null +++ b/Core/Core/Assets.xcassets/checkEmail.imageset/checkEmailLight.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/noWifi.imageset/Contents.json b/Core/Core/Assets.xcassets/noWifi.imageset/Contents.json new file mode 100644 index 000000000..8c7a6c0c8 --- /dev/null +++ b/Core/Core/Assets.xcassets/noWifi.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "noWifi.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/noWifi.imageset/noWifi.svg b/Core/Core/Assets.xcassets/noWifi.imageset/noWifi.svg new file mode 100644 index 000000000..052e9c275 --- /dev/null +++ b/Core/Core/Assets.xcassets/noWifi.imageset/noWifi.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/noWifiMini.imageset/Contents.json b/Core/Core/Assets.xcassets/noWifiMini.imageset/Contents.json new file mode 100644 index 000000000..8c7a6c0c8 --- /dev/null +++ b/Core/Core/Assets.xcassets/noWifiMini.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "noWifi.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/noWifiMini.imageset/noWifi.svg b/Core/Core/Assets.xcassets/noWifiMini.imageset/noWifi.svg new file mode 100644 index 000000000..d3244e4eb --- /dev/null +++ b/Core/Core/Assets.xcassets/noWifiMini.imageset/noWifi.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Core/Core/Assets.xcassets/notAvaliable.imageset/Contents.json b/Core/Core/Assets.xcassets/notAvaliable.imageset/Contents.json index 1869de7d4..d717fb05a 100644 --- a/Core/Core/Assets.xcassets/notAvaliable.imageset/Contents.json +++ b/Core/Core/Assets.xcassets/notAvaliable.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Frame 273-3.svg", + "filename" : "notAvaliableDark.svg", "idiom" : "universal" }, { @@ -11,7 +11,7 @@ "value" : "dark" } ], - "filename" : "Frame 273-4.svg", + "filename" : "notAvaliableLight.svg", "idiom" : "universal" } ], diff --git a/Core/Core/Assets.xcassets/notAvaliable.imageset/Frame 273-3.svg b/Core/Core/Assets.xcassets/notAvaliable.imageset/notAvaliableDark.svg similarity index 55% rename from Core/Core/Assets.xcassets/notAvaliable.imageset/Frame 273-3.svg rename to Core/Core/Assets.xcassets/notAvaliable.imageset/notAvaliableDark.svg index 628645fb6..5988af1a4 100644 --- a/Core/Core/Assets.xcassets/notAvaliable.imageset/Frame 273-3.svg +++ b/Core/Core/Assets.xcassets/notAvaliable.imageset/notAvaliableDark.svg @@ -1,11 +1,6 @@ - - - - - - - - - + + + + diff --git a/Core/Core/Assets.xcassets/notAvaliable.imageset/Frame 273-4.svg b/Core/Core/Assets.xcassets/notAvaliable.imageset/notAvaliableLight.svg similarity index 52% rename from Core/Core/Assets.xcassets/notAvaliable.imageset/Frame 273-4.svg rename to Core/Core/Assets.xcassets/notAvaliable.imageset/notAvaliableLight.svg index 9b2d26b91..ef4db81cd 100644 --- a/Core/Core/Assets.xcassets/notAvaliable.imageset/Frame 273-4.svg +++ b/Core/Core/Assets.xcassets/notAvaliable.imageset/notAvaliableLight.svg @@ -1,11 +1,6 @@ - - - - - - - - - + + + + diff --git a/Core/Core/Extensions/ViewExtension.swift b/Core/Core/Extensions/ViewExtension.swift index 446de5321..8520a84cb 100644 --- a/Core/Core/Extensions/ViewExtension.swift +++ b/Core/Core/Extensions/ViewExtension.swift @@ -14,13 +14,14 @@ public extension View { func cardStyle( top: CGFloat? = 0, bottom: CGFloat? = 0, + paddingAll: CGFloat? = 20, leftLineEnabled: Bool = false, bgColor: Color = Theme.Colors.background, strokeColor: Color = Theme.Colors.cardViewStroke, textColor: Color = Theme.Colors.textPrimary ) -> some View { return self - .padding(.all, 20) + .padding(.all, paddingAll) .padding(.vertical, leftLineEnabled ? 0 : 6) .font(Theme.Fonts.titleMedium) .frame(minWidth: 0, diff --git a/Core/Core/SwiftGen/Assets.swift b/Core/Core/SwiftGen/Assets.swift index c0f29a4bf..2252597b1 100644 --- a/Core/Core/SwiftGen/Assets.swift +++ b/Core/Core/SwiftGen/Assets.swift @@ -106,6 +106,8 @@ public enum CoreAssets { public static let readdleSpark = ImageAsset(name: "readdle-spark") public static let ymail = ImageAsset(name: "ymail") public static let noCourseImage = ImageAsset(name: "noCourseImage") + public static let noWifi = ImageAsset(name: "noWifi") + public static let noWifiMini = ImageAsset(name: "noWifiMini") public static let notAvaliable = ImageAsset(name: "notAvaliable") public static let playVideo = ImageAsset(name: "playVideo") public static let star = ImageAsset(name: "star") diff --git a/Core/Core/SwiftGen/Strings.swift b/Core/Core/SwiftGen/Strings.swift index dd7f3daba..d47476385 100644 --- a/Core/Core/SwiftGen/Strings.swift +++ b/Core/Core/SwiftGen/Strings.swift @@ -55,10 +55,8 @@ public enum CoreLocalization { public static let courseUnits = CoreLocalization.tr("Localizable", "COURSEWARE.COURSE_UNITS", fallback: "Course units") /// Finish public static let finish = CoreLocalization.tr("Localizable", "COURSEWARE.FINISH", fallback: "Finish") - /// Good Work! - public static let goodWork = CoreLocalization.tr("Localizable", "COURSEWARE.GOOD_WORK", fallback: "Good Work!") - /// “ is finished. - public static let isFinished = CoreLocalization.tr("Localizable", "COURSEWARE.IS_FINISHED", fallback: "“ is finished.") + /// Good job! + public static let goodWork = CoreLocalization.tr("Localizable", "COURSEWARE.GOOD_WORK", fallback: "Good job!") /// Next public static let next = CoreLocalization.tr("Localizable", "COURSEWARE.NEXT", fallback: "Next") /// Next section @@ -73,8 +71,10 @@ public enum CoreLocalization { public static let resume = CoreLocalization.tr("Localizable", "COURSEWARE.RESUME", fallback: "Resume") /// Resume with: public static let resumeWith = CoreLocalization.tr("Localizable", "COURSEWARE.RESUME_WITH", fallback: "Resume with:") - /// Section “ - public static let section = CoreLocalization.tr("Localizable", "COURSEWARE.SECTION", fallback: "Section “") + /// You've completed “%@”. + public static func sectionCompleted(_ p1: Any) -> String { + return CoreLocalization.tr("Localizable", "COURSEWARE.SECTION_COMPLETED", String(describing: p1), fallback: "You've completed “%@”.") + } } public enum Date { /// Ended @@ -117,6 +117,12 @@ public enum CoreLocalization { public static let userNotActive = CoreLocalization.tr("Localizable", "ERROR.USER_NOT_ACTIVE", fallback: "User account is not activated. Please activate your account first.") /// You can only download files over Wi-Fi. You can change this in the settings. public static let wifi = CoreLocalization.tr("Localizable", "ERROR.WIFI", fallback: "You can only download files over Wi-Fi. You can change this in the settings.") + public enum Internet { + /// Please connect to the internet to view this content. + public static let noInternetDescription = CoreLocalization.tr("Localizable", "ERROR.INTERNET.NO_INTERNET_DESCRIPTION", fallback: "Please connect to the internet to view this content.") + /// No internet connection + public static let noInternetTitle = CoreLocalization.tr("Localizable", "ERROR.INTERNET.NO_INTERNET_TITLE", fallback: "No internet connection") + } } public enum Mainscreen { /// Dashboard diff --git a/Core/Core/View/Base/AlertView.swift b/Core/Core/View/Base/AlertView.swift index 3b8ec66e7..388c70c54 100644 --- a/Core/Core/View/Base/AlertView.swift +++ b/Core/Core/View/Base/AlertView.swift @@ -281,19 +281,19 @@ public struct AlertView: View { }, label: { ZStack { Text(CoreLocalization.Alert.logout) - .foregroundColor(Theme.Colors.primaryButtonTextColor) + .foregroundColor(Theme.Colors.warningText) .font(Theme.Fonts.labelLarge) .frame(maxWidth: .infinity) .padding(.horizontal, 16) Image(systemName: "rectangle.portrait.and.arrow.right") - .foregroundColor(Theme.Colors.white) + .foregroundColor(Theme.Colors.warningText) .frame(minWidth: 190, minHeight: 48, alignment: .trailing) } .frame(maxWidth: 215, minHeight: 48) }) .background( Theme.Shapes.buttonShape - .fill(Theme.Colors.accentColor) + .fill(Theme.Colors.warning) ) .overlay( RoundedRectangle(cornerRadius: 8) diff --git a/Core/Core/en.lproj/Localizable.strings b/Core/Core/en.lproj/Localizable.strings index 4a4f52e84..8d868b239 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* +/* Localizable.strings Core @@ -22,16 +22,18 @@ "ERROR.WIFI" = "You can only download files over Wi-Fi. You can change this in the settings."; "ERROR.AUTHORIZATION_FAILED" = "Authorization failed."; +"ERROR.INTERNET.NO_INTERNET_TITLE" = "No internet connection"; +"ERROR.INTERNET.NO_INTERNET_DESCRIPTION" = "Please connect to the internet to view this content."; + "COURSEWARE.COURSE_CONTENT" = "Course content"; "COURSEWARE.COURSE_CONTENT_NOT_AVAILABLE" = "This interactive component isn't yet available on mobile."; "COURSEWARE.COURSE_UNITS" = "Course units"; "COURSEWARE.NEXT" = "Next"; "COURSEWARE.PREVIOUS" = "Prev"; "COURSEWARE.FINISH" = "Finish"; -"COURSEWARE.GOOD_WORK" = "Good Work!"; +"COURSEWARE.GOOD_WORK" = "Good job!"; "COURSEWARE.BACK_TO_OUTLINE" = "Back to outline"; -"COURSEWARE.SECTION" = "Section “"; -"COURSEWARE.IS_FINISHED" = "“ is finished."; +"COURSEWARE.SECTION_COMPLETED" = "You've completed “%@”."; "COURSEWARE.CONTINUE" = "Continue"; "COURSEWARE.RESUME" = "Resume"; "COURSEWARE.RESUME_WITH" = "Resume with:"; diff --git a/Core/Core/uk.lproj/Localizable.strings b/Core/Core/uk.lproj/Localizable.strings index 3f2e867b7..ce8cc2ac5 100644 --- a/Core/Core/uk.lproj/Localizable.strings +++ b/Core/Core/uk.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* +/* Localizable.strings Core @@ -21,6 +21,9 @@ "ERROR.UNKNOWN_ERROR" = "Щось пішло не так"; "ERROR.WIFI" = "Завантажувати файли можна лише через Wi-Fi. Ви можете змінити це в налаштуваннях."; +"ERROR.INTERNET.NO_INTERNET_TITLE" = "Немає підключення до Інтернету"; +"ERROR.INTERNET.NO_INTERNET_DESCRIPTION" = "Будь ласка, підключіться до Інтернету, щоб переглянути цей вміст."; + "COURSEWARE.COURSE_CONTENT" = "Зміст курсу"; "COURSEWARE.COURSE_CONTENT_NOT_AVAILABLE" = "This interactive component isn't yet available on mobile."; "COURSEWARE.COURSE_UNITS" = "Модулі"; @@ -29,8 +32,7 @@ "COURSEWARE.FINISH" = "Завершити"; "COURSEWARE.GOOD_WORK" = "Гарна робота!"; "COURSEWARE.BACK_TO_OUTLINE" = "Повернутись до модуля"; -"COURSEWARE.SECTION" = "Секція “"; -"COURSEWARE.IS_FINISHED" = "“ завершена."; +"COURSEWARE.SECTION_COMPLETED" = "Ви завершили “%@”."; "COURSEWARE.CONTINUE" = "Продовжити"; "COURSEWARE.RESUME" = "Resume"; "COURSEWARE.RESUME_WITH" = "Продовжити далі:"; diff --git a/Course/Course/Presentation/Unit/CourseNavigationView.swift b/Course/Course/Presentation/Unit/CourseNavigationView.swift index 0834019e3..fbcc8ef84 100644 --- a/Course/Course/Presentation/Unit/CourseNavigationView.swift +++ b/Course/Course/Presentation/Unit/CourseNavigationView.swift @@ -91,8 +91,7 @@ struct CourseNavigationView: View { viewModel.router.presentAlert( alertTitle: CourseLocalization.Courseware.goodWork, - alertMessage: (CourseLocalization.Courseware.section - + currentVertical.displayName + CourseLocalization.Courseware.isFinished), + alertMessage: (CoreLocalization.Courseware.sectionCompleted(currentVertical.displayName)), nextSectionName: { if let data = viewModel.nextData, let vertical = viewModel.vertical(for: data) { diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index e38c22879..56c387965 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -190,7 +190,7 @@ public struct CourseUnitView: View { Spacer(minLength: 150) } } else { - NoInternetView(playerStateSubject: playerStateSubject) + NoInternetView() } } else { @@ -220,7 +220,7 @@ public struct CourseUnitView: View { Spacer(minLength: 150) } } else { - NoInternetView(playerStateSubject: playerStateSubject) + NoInternetView() } } // MARK: Web @@ -234,7 +234,7 @@ public struct CourseUnitView: View { ) // not need to add frame limit there because we did that with injection } else { - NoInternetView(playerStateSubject: playerStateSubject) + NoInternetView() } } else { EmptyView() @@ -243,14 +243,12 @@ public struct CourseUnitView: View { case .unknown(let url): if index >= viewModel.index - 1 && index <= viewModel.index + 1 { if viewModel.connectivity.isInternetAvaliable { - ScrollView(showsIndicators: false) { - UnknownView(url: url, viewModel: viewModel) - .frameLimit(width: reader.size.width) - Spacer() - .frame(minHeight: 100) - } + UnknownView(url: url, viewModel: viewModel) + .frameLimit(width: reader.size.width) + Spacer() + .frame(minHeight: 100) } else { - NoInternetView(playerStateSubject: playerStateSubject) + NoInternetView() } } else { EmptyView() @@ -278,7 +276,7 @@ public struct CourseUnitView: View { //No need iPad paddings there bacause they were added //to PostsView that placed inside DiscussionView } else { - NoInternetView(playerStateSubject: playerStateSubject) + NoInternetView() } } else { EmptyView() @@ -565,7 +563,7 @@ struct CourseUnitView_Previews: PreviewProvider { config: ConfigMock(), router: CourseRouterMock(), analytics: CourseAnalyticsMock(), - connectivity: Connectivity(), + connectivity: Connectivity(), storage: CourseStorageMock(), manager: DownloadManagerMock() ), sectionName: "") @@ -575,20 +573,23 @@ struct CourseUnitView_Previews: PreviewProvider { #endif struct NoInternetView: View { - - let playerStateSubject: CurrentValueSubject - + var body: some View { VStack(spacing: 28) { - Image(systemName: "wifi").resizable() + Spacer() + CoreAssets.noWifi.swiftUIImage + .renderingMode(.template) + .foregroundStyle(Color.primary) .scaledToFit() - .frame(width: 100) - Text(CourseLocalization.Error.noInternet) + Text(CoreLocalization.Error.Internet.noInternetTitle) + .font(Theme.Fonts.titleLarge) + .foregroundColor(Theme.Colors.textPrimary) + Text(CoreLocalization.Error.Internet.noInternetDescription) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textPrimary) .multilineTextAlignment(.center) - .padding(.horizontal, 20) - UnitButtonView(type: .reload, action: { - playerStateSubject.send(VideoPlayerState.kill) - }).frame(width: 100) + .padding(.horizontal, 50) + Spacer() }.frame(maxWidth: .infinity, maxHeight: .infinity) } } diff --git a/Course/Course/Presentation/Unit/Subviews/UnknownView.swift b/Course/Course/Presentation/Unit/Subviews/UnknownView.swift index 6a41ae3e7..d38104a23 100644 --- a/Course/Course/Presentation/Unit/Subviews/UnknownView.swift +++ b/Course/Course/Presentation/Unit/Subviews/UnknownView.swift @@ -15,6 +15,7 @@ struct UnknownView: View { var body: some View { VStack(spacing: 0) { + Spacer() CoreAssets.notAvaliable.swiftUIImage Text(CourseLocalization.NotAvaliable.title) .font(Theme.Fonts.titleLarge) @@ -33,6 +34,7 @@ struct UnknownView: View { }) .frame(width: 215) .padding(.top, 40) + Spacer() } .padding(24) } diff --git a/Course/Course/SwiftGen/Strings.swift b/Course/Course/SwiftGen/Strings.swift index f61aef96c..bdcfddabe 100644 --- a/Course/Course/SwiftGen/Strings.swift +++ b/Course/Course/SwiftGen/Strings.swift @@ -41,18 +41,18 @@ public enum CourseLocalization { public static let courseUnits = CourseLocalization.tr("Localizable", "COURSEWARE.COURSE_UNITS", fallback: "Course units") /// Finish public static let finish = CourseLocalization.tr("Localizable", "COURSEWARE.FINISH", fallback: "Finish") - /// Good Work! - public static let goodWork = CourseLocalization.tr("Localizable", "COURSEWARE.GOOD_WORK", fallback: "Good Work!") - /// “ is finished. - public static let isFinished = CourseLocalization.tr("Localizable", "COURSEWARE.IS_FINISHED", fallback: "“ is finished.") + /// Good job! + public static let goodWork = CourseLocalization.tr("Localizable", "COURSEWARE.GOOD_WORK", fallback: "Good job!") + /// “. + public static let isFinished = CourseLocalization.tr("Localizable", "COURSEWARE.IS_FINISHED", fallback: "“.") /// Next public static let next = CourseLocalization.tr("Localizable", "COURSEWARE.NEXT", fallback: "Next") /// Prev public static let previous = CourseLocalization.tr("Localizable", "COURSEWARE.PREVIOUS", fallback: "Prev") /// Resume with: public static let resumeWith = CourseLocalization.tr("Localizable", "COURSEWARE.RESUME_WITH", fallback: "Resume with:") - /// Section “ - public static let section = CourseLocalization.tr("Localizable", "COURSEWARE.SECTION", fallback: "Section “") + /// You've completed “ + public static let section = CourseLocalization.tr("Localizable", "COURSEWARE.SECTION", fallback: "You've completed “") } public enum CourseContainer { /// Course @@ -61,8 +61,8 @@ public enum CourseLocalization { public static let dates = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.DATES", fallback: "Dates") /// Discussions public static let discussions = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.DISCUSSIONS", fallback: "Discussions") - /// Handouts - public static let handouts = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.HANDOUTS", fallback: "Handouts") + /// More + public static let handouts = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.HANDOUTS", fallback: "More") /// Handouts In developing public static let handoutsInDeveloping = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.HANDOUTS_IN_DEVELOPING", fallback: "Handouts In developing") /// Videos @@ -231,8 +231,8 @@ public enum CourseLocalization { public static let button = CourseLocalization.tr("Localizable", "NOT_AVALIABLE.BUTTON", fallback: "Open in browser") /// Explore other parts of this course or view this on web. public static let description = CourseLocalization.tr("Localizable", "NOT_AVALIABLE.DESCRIPTION", fallback: "Explore other parts of this course or view this on web.") - /// This interactive component isn’t yet available - public static let title = CourseLocalization.tr("Localizable", "NOT_AVALIABLE.TITLE", fallback: "This interactive component isn’t yet available") + /// This interactive component isn't available on mobile + public static let title = CourseLocalization.tr("Localizable", "NOT_AVALIABLE.TITLE", fallback: "This interactive component isn't available on mobile") } public enum Outline { /// Certificate @@ -246,8 +246,8 @@ public enum CourseLocalization { public static let courseHasntStarted = CourseLocalization.tr("Localizable", "OUTLINE.COURSE_HASNT_STARTED", fallback: "This course hasn't started yet.") /// Course videos public static let courseVideos = CourseLocalization.tr("Localizable", "OUTLINE.COURSE_VIDEOS", fallback: "Course videos") - /// You've passed the course - public static let passedTheCourse = CourseLocalization.tr("Localizable", "OUTLINE.PASSED_THE_COURSE", fallback: "You've passed the course") + /// You’ve completed the course + public static let passedTheCourse = CourseLocalization.tr("Localizable", "OUTLINE.PASSED_THE_COURSE", fallback: "You’ve completed the course") /// View certificate public static let viewCertificate = CourseLocalization.tr("Localizable", "OUTLINE.VIEW_CERTIFICATE", fallback: "View certificate") } diff --git a/Course/Course/en.lproj/Localizable.strings b/Course/Course/en.lproj/Localizable.strings index 52d82216c..5f785ca8d 100644 --- a/Course/Course/en.lproj/Localizable.strings +++ b/Course/Course/en.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* +/* Localizable.strings Course @@ -7,7 +7,7 @@ */ "OUTLINE.CONGRATULATIONS" = "Congratulations!"; -"OUTLINE.PASSED_THE_COURSE" = "You've passed the course"; +"OUTLINE.PASSED_THE_COURSE" = "You’ve completed the course"; "OUTLINE.VIEW_CERTIFICATE" = "View certificate"; "OUTLINE.CERTIFICATE" = "Certificate"; "OUTLINE.COURSE_VIDEOS" = "Course videos"; @@ -18,10 +18,10 @@ "COURSEWARE.NEXT" = "Next"; "COURSEWARE.PREVIOUS" = "Prev"; "COURSEWARE.FINISH" = "Finish"; -"COURSEWARE.GOOD_WORK" = "Good Work!"; +"COURSEWARE.GOOD_WORK" = "Good job!"; "COURSEWARE.BACK_TO_OUTLINE" = "Back to outline"; -"COURSEWARE.SECTION" = "Section “"; -"COURSEWARE.IS_FINISHED" = "“ is finished."; +"COURSEWARE.SECTION" = "You've completed “"; +"COURSEWARE.IS_FINISHED" = "“."; "COURSEWARE.CONTINUE" = "Continue"; "COURSEWARE.RESUME_WITH" = "Resume with:"; @@ -40,7 +40,7 @@ "COURSE_CONTAINER.VIDEOS" = "Videos"; "COURSE_CONTAINER.DATES" = "Dates"; "COURSE_CONTAINER.DISCUSSIONS" = "Discussions"; -"COURSE_CONTAINER.HANDOUTS" = "Handouts"; +"COURSE_CONTAINER.HANDOUTS" = "More"; "COURSE_CONTAINER.HANDOUTS_IN_DEVELOPING" = "Handouts In developing"; "HANDOUTS_CELL_HANDOUTS.TITLE" = "Handouts"; @@ -48,7 +48,7 @@ "HANDOUTS_CELL_HANDOUTS.DESCRIPTION" = "Find important course information"; "HANDOUTS_CELL_ANNOUNCEMENTS.DESCRIPTION" = "Keep up with the latest news"; -"NOT_AVALIABLE.TITLE" = "This interactive component isn’t yet available"; +"NOT_AVALIABLE.TITLE" = "This interactive component isn't available on mobile"; "NOT_AVALIABLE.DESCRIPTION" = "Explore other parts of this course or view this on web."; "NOT_AVALIABLE.BUTTON" = "Open in browser"; diff --git a/Dashboard/Dashboard/Presentation/DashboardView.swift b/Dashboard/Dashboard/Presentation/DashboardView.swift index c896acfd3..44c6c3fc8 100644 --- a/Dashboard/Dashboard/Presentation/DashboardView.swift +++ b/Dashboard/Dashboard/Presentation/DashboardView.swift @@ -43,16 +43,16 @@ public struct DashboardView: View { await viewModel.getMyCourses(page: 1, refresh: true) }) { Group { - if viewModel.courses.isEmpty && !viewModel.fetchInProgress { - EmptyPageIcon() - } else { - LazyVStack(spacing: 0) { - HStack { - dashboardCourses - .padding(.horizontal, 20) - .padding(.bottom, 20) - Spacer() - }.padding(.leading, 10) + LazyVStack(spacing: 0) { + HStack { + dashboardCourses + .padding(.horizontal, 20) + .padding(.bottom, 20) + Spacer() + }.padding(.leading, 10) + if viewModel.courses.isEmpty && !viewModel.fetchInProgress { + EmptyPageIcon() + } else { ForEach(Array(viewModel.courses.enumerated()), id: \.offset) { index, course in @@ -164,11 +164,6 @@ struct EmptyPageIcon: View { CoreAssets.dashboardEmptyPage.swiftUIImage .padding(.bottom, 16) .accessibilityIdentifier("empty_page_image") - Text(DashboardLocalization.Empty.title) - .font(Theme.Fonts.titleMedium) - .foregroundColor(Theme.Colors.textPrimary) - .padding(.bottom, 8) - .accessibilityIdentifier("empty_page_title_text") Text(DashboardLocalization.Empty.subtitle) .font(Theme.Fonts.bodySmall) .foregroundColor(Theme.Colors.textSecondary) diff --git a/Dashboard/Dashboard/SwiftGen/Strings.swift b/Dashboard/Dashboard/SwiftGen/Strings.swift index 8067cdfab..aac74931c 100644 --- a/Dashboard/Dashboard/SwiftGen/Strings.swift +++ b/Dashboard/Dashboard/SwiftGen/Strings.swift @@ -18,8 +18,6 @@ public enum DashboardLocalization { public enum Empty { /// You are not enrolled in any courses yet. public static let subtitle = DashboardLocalization.tr("Localizable", "EMPTY.SUBTITLE", fallback: "You are not enrolled in any courses yet.") - /// It's empty - public static let title = DashboardLocalization.tr("Localizable", "EMPTY.TITLE", fallback: "It's empty") } public enum Header { /// Courses diff --git a/Dashboard/Dashboard/en.lproj/Localizable.strings b/Dashboard/Dashboard/en.lproj/Localizable.strings index 20d6b1307..88fc5d371 100644 --- a/Dashboard/Dashboard/en.lproj/Localizable.strings +++ b/Dashboard/Dashboard/en.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* +/* Localizable.strings Dashboard @@ -10,5 +10,4 @@ "HEADER.COURSES" = "Courses"; "HEADER.WELCOME_BACK" = "Welcome back. Let's keep learning."; -"EMPTY.TITLE" = "It's empty"; "EMPTY.SUBTITLE" = "You are not enrolled in any courses yet."; diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift index c4ed19e32..427cd4ade 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift @@ -99,19 +99,19 @@ public struct CourseDetailsView: View { viewModel?.showCourseVideo() }) }.aspectRatio(CGSize(width: 16, height: 8.5), contentMode: .fill) -// .frame(maxHeight: 250) .cornerRadius(12) .padding(.horizontal, 6) .padding(.top, 7) .fixedSize(horizontal: false, vertical: true) - // MARK: - Title and description - CourseTitleView(courseDetails: courseDetails) - // MARK: - Course state button CourseStateView(title: title, courseDetails: courseDetails, viewModel: viewModel) + .padding(.top, 24) + + // MARK: - Title and description + CourseTitleView(courseDetails: courseDetails) } // MARK: - HTML Embed @@ -177,10 +177,12 @@ public struct CourseDetailsView: View { } // MARK: - Offline mode SnackBar - OfflineSnackBarView(connectivity: viewModel.connectivity, - reloadAction: { - await viewModel.getCourseDetail(courseID: courseID, withProgress: false) - }) + if viewModel.courseState() != .enrollOpen { + OfflineSnackBarView(connectivity: viewModel.connectivity, + reloadAction: { + await viewModel.getCourseDetail(courseID: courseID, withProgress: false) + }) + } // MARK: - Error Alert if viewModel.showError { @@ -222,20 +224,34 @@ private struct CourseStateView: View { var body: some View { switch viewModel.courseState() { case .enrollOpen: - StyledButton(DiscoveryLocalization.Details.enrollNow, action: { - if !viewModel.userloggedIn { - viewModel.router.showRegisterScreen( - sourceScreen: .courseDetail( - courseDetails.courseID, - courseDetails.courseTitle) - ) + Group { + if viewModel.connectivity.isInternetAvaliable { + StyledButton(DiscoveryLocalization.Details.enrollNow, action: { + if !viewModel.userloggedIn { + viewModel.router.showRegisterScreen( + sourceScreen: .courseDetail( + courseDetails.courseID, + courseDetails.courseTitle) + ) + } else { + Task { + await viewModel.enrollToCourse(id: courseDetails.courseID) + } + } + }) + .padding(16) } else { - Task { - await viewModel.enrollToCourse(id: courseDetails.courseID) - } + HStack(alignment: .center, spacing: 10) { + CoreAssets.noWifiMini.swiftUIImage + .renderingMode(.template) + .foregroundStyle(Theme.Colors.warning) + Text(DiscoveryLocalization.Details.enrollmentNoInternet) + .multilineTextAlignment(.leading) + .font(Theme.Fonts.titleSmall) + Spacer() + }.cardStyle(paddingAll: 12, bgColor: Theme.Colors.textInputUnfocusedBackground, strokeColor: .clear) } - }) - .padding(16) + } .accessibilityIdentifier("enroll_button") case .enrollClose: Text(DiscoveryLocalization.Details.enrollmentDateIsOver) diff --git a/Discovery/Discovery/SwiftGen/Strings.swift b/Discovery/Discovery/SwiftGen/Strings.swift index 8bd26400c..987aec582 100644 --- a/Discovery/Discovery/SwiftGen/Strings.swift +++ b/Discovery/Discovery/SwiftGen/Strings.swift @@ -52,6 +52,8 @@ public enum DiscoveryLocalization { public static let enrollNow = DiscoveryLocalization.tr("Localizable", "DETAILS.ENROLL_NOW", fallback: "Enroll now") /// You cannot enroll in this course because the enrollment date is over. public static let enrollmentDateIsOver = DiscoveryLocalization.tr("Localizable", "DETAILS.ENROLLMENT_DATE_IS_OVER", fallback: "You cannot enroll in this course because the enrollment date is over.") + /// To enroll in this course, please make sure you are connected to the internet. + public static let enrollmentNoInternet = DiscoveryLocalization.tr("Localizable", "DETAILS.ENROLLMENT_NO_INTERNET", fallback: "To enroll in this course, please make sure you are connected to the internet.") /// Course details public static let title = DiscoveryLocalization.tr("Localizable", "DETAILS.TITLE", fallback: "Course details") /// View course diff --git a/Discovery/Discovery/en.lproj/Localizable.strings b/Discovery/Discovery/en.lproj/Localizable.strings index 09bd81912..8792ba146 100644 --- a/Discovery/Discovery/en.lproj/Localizable.strings +++ b/Discovery/Discovery/en.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* +/* Localizable.strings Discovery @@ -34,3 +34,4 @@ "DETAILS.VIEW_COURSE" = "View course"; "DETAILS.ENROLL_NOW" = "Enroll now"; "DETAILS.ENROLLMENT_DATE_IS_OVER" = "You cannot enroll in this course because the enrollment date is over."; +"DETAILS.ENROLLMENT_NO_INTERNET" = "To enroll in this course, please make sure you are connected to the internet."; diff --git a/Discovery/Discovery/uk.lproj/Localizable.strings b/Discovery/Discovery/uk.lproj/Localizable.strings index 28f832e27..25f73bf53 100644 --- a/Discovery/Discovery/uk.lproj/Localizable.strings +++ b/Discovery/Discovery/uk.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* +/* Localizable.strings Discovery @@ -34,3 +34,4 @@ "DETAILS.VIEW_COURSE" = "Переглянути курс"; "DETAILS.ENROLL_NOW" = "Зареєструватися"; "DETAILS.ENROLLMENT_DATE_IS_OVER" = "Ви не можете зареєструватися на цей курс, оскільки дата реєстрації закінчилася."; +"DETAILS.ENROLLMENT_NO_INTERNET" = "Щоб зареєструватися на цьому курсі, переконайтеся, що ви підключені до Інтернету."; diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 043a37fbf..12b243007 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -43,8 +43,8 @@ 07A7D79028F5C9060000BE81 /* Core.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 07A7D78E28F5C9060000BE81 /* Core.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 07D5DA3528D075AA00752FD9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07D5DA3428D075AA00752FD9 /* AppDelegate.swift */; }; 07D5DA3E28D075AB00752FD9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 07D5DA3D28D075AB00752FD9 /* Assets.xcassets */; }; + 0AF04487E2B198E7A14F037F /* Pods_App_OpenEdX.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FEBF1DCBDEE79A04144660EB /* Pods_App_OpenEdX.framework */; }; 149FF39E2B9F1AB50034B33F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 149FF39C2B9F1AB50034B33F /* LaunchScreen.storyboard */; }; - 95C140F3BDF778364986E83B /* Pods_App_OpenEdX.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F138C15C3A2515F8F94DAA8B /* Pods_App_OpenEdX.framework */; }; A500668B2B613ED10024680B /* PushNotificationsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500668A2B613ED10024680B /* PushNotificationsManager.swift */; }; A500668D2B6143000024680B /* FCMProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A500668C2B6143000024680B /* FCMProvider.swift */; }; A50066912B61467B0024680B /* BrazeProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = A50066902B61467B0024680B /* BrazeProvider.swift */; }; @@ -126,11 +126,9 @@ 07D5DA3128D075AA00752FD9 /* OpenEdX.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenEdX.app; sourceTree = BUILT_PRODUCTS_DIR; }; 07D5DA3428D075AA00752FD9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 07D5DA3D28D075AB00752FD9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - 1499CCAD7A0D8A3E6AF39794 /* Pods-App-OpenEdX.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugprod.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugprod.xcconfig"; sourceTree = ""; }; + 0F08B37CE833845FF5CD43E0 /* Pods-App-OpenEdX.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasestage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasestage.xcconfig"; sourceTree = ""; }; 149FF39D2B9F1AB50034B33F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 6F54C19C823A769E18923FA8 /* Pods-App-OpenEdX.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugstage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugstage.xcconfig"; sourceTree = ""; }; - 8284179FC05AEE2591573E20 /* Pods-App-OpenEdX.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugdev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugdev.xcconfig"; sourceTree = ""; }; - A24D6A8E1BC4DF46AD68904C /* Pods-App-OpenEdX.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releaseprod.xcconfig"; sourceTree = ""; }; + 5E9E8EEB795809CB9424EBA6 /* Pods-App-OpenEdX.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releaseprod.xcconfig"; sourceTree = ""; }; A500668A2B613ED10024680B /* PushNotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationsManager.swift; sourceTree = ""; }; A500668C2B6143000024680B /* FCMProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FCMProvider.swift; sourceTree = ""; }; A50066902B61467B0024680B /* BrazeProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrazeProvider.swift; sourceTree = ""; }; @@ -142,11 +140,13 @@ A59568982B616D9400ED4F90 /* PushLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushLink.swift; sourceTree = ""; }; A59702282B83C87900CA064C /* FirebaseAnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseAnalyticsService.swift; sourceTree = ""; }; A5C10D8E2B861A70008E864D /* SegmentAnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentAnalyticsService.swift; sourceTree = ""; }; - A89AD827F52CF6A6B903606E /* Pods-App-OpenEdX.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasestage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasestage.xcconfig"; sourceTree = ""; }; BA7468752B96201D00793145 /* DeepLinkRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeepLinkRouter.swift; sourceTree = ""; }; - BB08ACD2CCA33D6DDDDD31B4 /* Pods-App-OpenEdX.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasedev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasedev.xcconfig"; sourceTree = ""; }; + BEDEC3C1F88936685DCF7AE5 /* Pods-App-OpenEdX.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugdev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugdev.xcconfig"; sourceTree = ""; }; + C9D8705F03FC185BBE66984C /* Pods-App-OpenEdX.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugprod.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugprod.xcconfig"; sourceTree = ""; }; + D19FBC727E9AD036986A0B8D /* Pods-App-OpenEdX.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.releasedev.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.releasedev.xcconfig"; sourceTree = ""; }; + E02715409EC1643835C9EFEE /* Pods-App-OpenEdX.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-OpenEdX.debugstage.xcconfig"; path = "Target Support Files/Pods-App-OpenEdX/Pods-App-OpenEdX.debugstage.xcconfig"; sourceTree = ""; }; E0D6E6A22B1626B10089F9C9 /* Theme.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Theme.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - F138C15C3A2515F8F94DAA8B /* Pods_App_OpenEdX.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_OpenEdX.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + FEBF1DCBDEE79A04144660EB /* Pods_App_OpenEdX.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_OpenEdX.framework; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -166,9 +166,9 @@ 0219C67728F4347600D64452 /* Course.framework in Frameworks */, 027DB33028D8A063002B6862 /* Dashboard.framework in Frameworks */, A5BD3E302B83B0F7006A8983 /* SegmentFirebase in Frameworks */, - 95C140F3BDF778364986E83B /* Pods_App_OpenEdX.framework in Frameworks */, A51CDBED2B6D2BEE009B6D4E /* SegmentBraze in Frameworks */, A51CDBEF2B6D2BEE009B6D4E /* SegmentBrazeUI in Frameworks */, + 0AF04487E2B198E7A14F037F /* Pods_App_OpenEdX.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -261,7 +261,7 @@ 072787B028D34D83002E9142 /* Discovery.framework */, 0770DE4A28D0A462006D8A5D /* Authorization.framework */, 0770DE1228D07845006D8A5D /* Core.framework */, - F138C15C3A2515F8F94DAA8B /* Pods_App_OpenEdX.framework */, + FEBF1DCBDEE79A04144660EB /* Pods_App_OpenEdX.framework */, ); name = Frameworks; sourceTree = ""; @@ -269,12 +269,12 @@ 55A895025FB07897BA68E063 /* Pods */ = { isa = PBXGroup; children = ( - 1499CCAD7A0D8A3E6AF39794 /* Pods-App-OpenEdX.debugprod.xcconfig */, - 6F54C19C823A769E18923FA8 /* Pods-App-OpenEdX.debugstage.xcconfig */, - 8284179FC05AEE2591573E20 /* Pods-App-OpenEdX.debugdev.xcconfig */, - A24D6A8E1BC4DF46AD68904C /* Pods-App-OpenEdX.releaseprod.xcconfig */, - A89AD827F52CF6A6B903606E /* Pods-App-OpenEdX.releasestage.xcconfig */, - BB08ACD2CCA33D6DDDDD31B4 /* Pods-App-OpenEdX.releasedev.xcconfig */, + C9D8705F03FC185BBE66984C /* Pods-App-OpenEdX.debugprod.xcconfig */, + E02715409EC1643835C9EFEE /* Pods-App-OpenEdX.debugstage.xcconfig */, + BEDEC3C1F88936685DCF7AE5 /* Pods-App-OpenEdX.debugdev.xcconfig */, + 5E9E8EEB795809CB9424EBA6 /* Pods-App-OpenEdX.releaseprod.xcconfig */, + 0F08B37CE833845FF5CD43E0 /* Pods-App-OpenEdX.releasestage.xcconfig */, + D19FBC727E9AD036986A0B8D /* Pods-App-OpenEdX.releasedev.xcconfig */, ); path = Pods; sourceTree = ""; @@ -387,7 +387,7 @@ isa = PBXNativeTarget; buildConfigurationList = 07D5DA4528D075AB00752FD9 /* Build configuration list for PBXNativeTarget "OpenEdX" */; buildPhases = ( - 3165870BC90D2FA438CFF0A9 /* [CP] Check Pods Manifest.lock */, + CC61D82AF6FA18BD30E5E8E5 /* [CP] Check Pods Manifest.lock */, 0770DE2328D08647006D8A5D /* SwiftLint */, 07D5DA2D28D075AA00752FD9 /* Sources */, 07D5DA2E28D075AA00752FD9 /* Frameworks */, @@ -509,7 +509,7 @@ shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/SwiftLint/swiftlint\"\n"; }; - 3165870BC90D2FA438CFF0A9 /* [CP] Check Pods Manifest.lock */ = { + CC61D82AF6FA18BD30E5E8E5 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -691,7 +691,7 @@ }; 02DD1C9629E80CC200F35DCE /* DebugStage */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 6F54C19C823A769E18923FA8 /* Pods-App-OpenEdX.debugstage.xcconfig */; + baseConfigurationReference = E02715409EC1643835C9EFEE /* Pods-App-OpenEdX.debugstage.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -779,7 +779,7 @@ }; 02DD1C9829E80CCB00F35DCE /* ReleaseStage */ = { isa = XCBuildConfiguration; - baseConfigurationReference = A89AD827F52CF6A6B903606E /* Pods-App-OpenEdX.releasestage.xcconfig */; + baseConfigurationReference = 0F08B37CE833845FF5CD43E0 /* Pods-App-OpenEdX.releasestage.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -873,7 +873,7 @@ }; 0727875928D231FD002E9142 /* DebugDev */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 8284179FC05AEE2591573E20 /* Pods-App-OpenEdX.debugdev.xcconfig */; + baseConfigurationReference = BEDEC3C1F88936685DCF7AE5 /* Pods-App-OpenEdX.debugdev.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -961,7 +961,7 @@ }; 0727875B28D23204002E9142 /* ReleaseDev */ = { isa = XCBuildConfiguration; - baseConfigurationReference = BB08ACD2CCA33D6DDDDD31B4 /* Pods-App-OpenEdX.releasedev.xcconfig */; + baseConfigurationReference = D19FBC727E9AD036986A0B8D /* Pods-App-OpenEdX.releasedev.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -1109,7 +1109,7 @@ }; 07D5DA4628D075AB00752FD9 /* DebugProd */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 1499CCAD7A0D8A3E6AF39794 /* Pods-App-OpenEdX.debugprod.xcconfig */; + baseConfigurationReference = C9D8705F03FC185BBE66984C /* Pods-App-OpenEdX.debugprod.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -1143,7 +1143,7 @@ }; 07D5DA4728D075AB00752FD9 /* ReleaseProd */ = { isa = XCBuildConfiguration; - baseConfigurationReference = A24D6A8E1BC4DF46AD68904C /* Pods-App-OpenEdX.releaseprod.xcconfig */; + baseConfigurationReference = 5E9E8EEB795809CB9424EBA6 /* Pods-App-OpenEdX.releaseprod.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift index b2467d82c..8909ddfc6 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift @@ -175,6 +175,7 @@ public struct EditProfileView: View { HStack(alignment: .top, spacing: 6) { CoreAssets.alarm.swiftUIImage.renderingMode(.template) Text(viewModel.alertMessage ?? "") + .font(Theme.Fonts.labelLarge) }.shadowCardStyle(bgColor: Theme.Colors.warning, textColor: .black) .transition(.move(edge: .bottom)) diff --git a/Profile/Profile/SwiftGen/Strings.swift b/Profile/Profile/SwiftGen/Strings.swift index 69559d7c8..d1adacf55 100644 --- a/Profile/Profile/SwiftGen/Strings.swift +++ b/Profile/Profile/SwiftGen/Strings.swift @@ -56,8 +56,8 @@ public enum ProfileLocalization { public static let backToProfile = ProfileLocalization.tr("Localizable", "DELETE_ACCOUNT.BACK_TO_PROFILE", fallback: "Back to profile") /// Yes, delete account public static let comfirm = ProfileLocalization.tr("Localizable", "DELETE_ACCOUNT.COMFIRM", fallback: "Yes, delete account") - /// To confirm this action you need to enter your account password. - public static let description = ProfileLocalization.tr("Localizable", "DELETE_ACCOUNT.DESCRIPTION", fallback: "To confirm this action you need to enter your account password.") + /// To confirm this action, please enter your account password. + public static let description = ProfileLocalization.tr("Localizable", "DELETE_ACCOUNT.DESCRIPTION", fallback: "To confirm this action, please enter your account password.") /// The password is incorrect. Please try again. public static let incorrectPassword = ProfileLocalization.tr("Localizable", "DELETE_ACCOUNT.INCORRECT_PASSWORD", fallback: "The password is incorrect. Please try again.") /// Password @@ -148,10 +148,10 @@ public enum ProfileLocalization { public static let wifiTitle = ProfileLocalization.tr("Localizable", "SETTINGS.WIFI_TITLE", fallback: "Wi-fi only download") } public enum UnsavedDataAlert { - /// Changes you have made may not be saved. - public static let text = ProfileLocalization.tr("Localizable", "UNSAVED_DATA_ALERT.TEXT", fallback: "Changes you have made may not be saved.") - /// Leave profile? - public static let title = ProfileLocalization.tr("Localizable", "UNSAVED_DATA_ALERT.TITLE", fallback: "Leave profile?") + /// Changes you have made will be discarded. + public static let text = ProfileLocalization.tr("Localizable", "UNSAVED_DATA_ALERT.TEXT", fallback: "Changes you have made will be discarded.") + /// Leave without saving? + public static let title = ProfileLocalization.tr("Localizable", "UNSAVED_DATA_ALERT.TITLE", fallback: "Leave without saving?") } } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length diff --git a/Profile/Profile/en.lproj/Localizable.strings b/Profile/Profile/en.lproj/Localizable.strings index 8f88084ea..df2a437f8 100644 --- a/Profile/Profile/en.lproj/Localizable.strings +++ b/Profile/Profile/en.lproj/Localizable.strings @@ -1,4 +1,4 @@ -/* +/* Localizable.strings Profile @@ -32,8 +32,8 @@ "DELETE_ALERT.TITLE" = "Warning!"; "DELETE_ALERT.TEXT" = "Do you really want to delete your account?"; -"UNSAVED_DATA_ALERT.TITLE" = "Leave profile?"; -"UNSAVED_DATA_ALERT.TEXT" = "Changes you have made may not be saved."; +"UNSAVED_DATA_ALERT.TITLE" = "Leave without saving?"; +"UNSAVED_DATA_ALERT.TEXT" = "Changes you have made will be discarded."; "EDIT.TOO_YONG_USER" = "You must be over 13 years old to have a profile with full access to information."; "EDIT.LIMITED_PROFILE_DESCRIPTION" = "A limited profile only shares your username and profile photo."; @@ -52,7 +52,7 @@ "DELETE_ACCOUNT.TITLE" = "Delete account"; "DELETE_ACCOUNT.ARE_YOU_SURE" = "Are you sure you want to "; "DELETE_ACCOUNT.WANT_TO_DELETE" = "delete your account?"; -"DELETE_ACCOUNT.DESCRIPTION" = "To confirm this action you need to enter your account password."; +"DELETE_ACCOUNT.DESCRIPTION" = "To confirm this action, please enter your account password."; "DELETE_ACCOUNT.PASSWORD" = "Password"; "DELETE_ACCOUNT.PASSWORD_DESCRIPTION" = "Enter password"; "DELETE_ACCOUNT.COMFIRM" = "Yes, delete account"; diff --git a/Theme/Theme/Assets.xcassets/Colors/warningText.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/warningText.colorset/Contents.json new file mode 100644 index 000000000..be9d677bb --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/warningText.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x00", + "green" : "0x00", + "red" : "0x00" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/SwiftGen/ThemeAssets.swift b/Theme/Theme/SwiftGen/ThemeAssets.swift index 7c25fd7b6..4eeb5d2c3 100644 --- a/Theme/Theme/SwiftGen/ThemeAssets.swift +++ b/Theme/Theme/SwiftGen/ThemeAssets.swift @@ -70,6 +70,7 @@ public enum ThemeAssets { public static let toggleSwitchColor = ColorAsset(name: "ToggleSwitchColor") public static let navigationBarTintColor = ColorAsset(name: "navigationBarTintColor") public static let warning = ColorAsset(name: "warning") + public static let warningText = ColorAsset(name: "warningText") public static let white = ColorAsset(name: "white") public static let appLogo = ImageAsset(name: "appLogo") } diff --git a/Theme/Theme/Theme.swift b/Theme/Theme/Theme.swift index c36bd18db..473eb9c0a 100644 --- a/Theme/Theme/Theme.swift +++ b/Theme/Theme/Theme.swift @@ -44,6 +44,7 @@ public struct Theme { public private(set) static var textInputUnfocusedBackground = ThemeAssets.textInputUnfocusedBackground.swiftUIColor public private(set) static var textInputUnfocusedStroke = ThemeAssets.textInputUnfocusedStroke.swiftUIColor public private(set) static var warning = ThemeAssets.warning.swiftUIColor + public private(set) static var warningText = ThemeAssets.warningText.swiftUIColor public private(set) static var white = ThemeAssets.white.swiftUIColor public private(set) static var onProgress = ThemeAssets.onProgress.swiftUIColor public private(set) static var progressDone = ThemeAssets.progressDone.swiftUIColor From 546e6d74b0926889bfa80a55dd0f3f99843ef599 Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Fri, 29 Mar 2024 14:16:21 +0500 Subject: [PATCH 108/136] chore: design team feedback to improve app theming capability (#365) * chore: design team feedback to improve app theming capability * chore: resolve conflicts * chore: address review feedback * chore: revert and update sign in & sign up subtitles --- .../Presentation/Base/FieldsView.swift | 2 +- .../Presentation/Login/SignInView.swift | 29 ++++++----- .../Registration/SignUpView.swift | 6 +-- .../Reset Password/ResetPasswordView.swift | 11 +++-- .../Presentation/Startup/StartupView.swift | 17 ++++--- .../Authorization/SwiftGen/Strings.swift | 10 ++-- .../en.lproj/Localizable.strings | 5 +- .../uk.lproj/Localizable.strings | 5 +- Core/Core/SwiftGen/Strings.swift | 4 +- .../View/Base/FlexibleKeyboardInputView.swift | 15 +++--- .../View/Base/LogistrationBottomView.swift | 4 +- Core/Core/View/Base/PickerMenu.swift | 18 +++++-- Core/Core/View/Base/PickerView.swift | 2 +- .../View/Base/RegistrationTextField.swift | 5 +- Core/Core/en.lproj/Localizable.strings | 2 +- Core/Core/uk.lproj/Localizable.strings | 2 +- .../Subviews/LessonLineProgressView.swift | 2 +- .../NativeDiscovery/SearchView.swift | 2 +- .../CreateNewThread/CreateNewThreadView.swift | 8 ++-- .../DiscussionSearchTopicsView.swift | 27 +++++------ .../DiscussionTopicsView.swift | 6 +-- .../DeleteAccount/DeleteAccountView.swift | 19 ++++---- .../EditProfile/EditProfileView.swift | 2 +- .../InfoColor.colorset copy/Contents.json | 38 +++++++++++++++ .../Colors/InfoColor.colorset/Contents.json | 38 +++++++++++++++ .../Contents.json | 38 +++++++++++++++ .../IrreversibleAlert.colorset/Contents.json | 38 +++++++++++++++ .../Contents.json | 38 +++++++++++++++ .../TextInputTextColor.colorset/Contents.json | 38 +++++++++++++++ Theme/Theme/SwiftGen/ThemeAssets.swift | 4 ++ Theme/Theme/Theme.swift | 48 ++++++++++++++++++- 31 files changed, 389 insertions(+), 94 deletions(-) create mode 100644 Theme/Theme/Assets.xcassets/Colors/InfoColor.colorset copy/Contents.json create mode 100644 Theme/Theme/Assets.xcassets/Colors/InfoColor.colorset/Contents.json create mode 100644 Theme/Theme/Assets.xcassets/Colors/IrreversibleAlert.colorset copy/Contents.json create mode 100644 Theme/Theme/Assets.xcassets/Colors/IrreversibleAlert.colorset/Contents.json create mode 100644 Theme/Theme/Assets.xcassets/Colors/TextInput/TextInputPlaceholderColor.colorset/Contents.json create mode 100644 Theme/Theme/Assets.xcassets/Colors/TextInput/TextInputTextColor.colorset/Contents.json diff --git a/Authorization/Authorization/Presentation/Base/FieldsView.swift b/Authorization/Authorization/Presentation/Base/FieldsView.swift index d05eac80f..e69cce798 100644 --- a/Authorization/Authorization/Presentation/Base/FieldsView.swift +++ b/Authorization/Authorization/Presentation/Base/FieldsView.swift @@ -94,7 +94,7 @@ struct FieldsView: View { } } Text(.init(text)) - .tint(Theme.Colors.accentXColor) + .tint(Theme.Colors.infoColor) .foregroundStyle(Theme.Colors.textSecondaryLight) .font(Theme.Fonts.labelSmall) .padding(.vertical, 3) diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index c3a45ab15..f56698803 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -70,22 +70,24 @@ public struct SignInView: View { .foregroundColor(Theme.Colors.textPrimary) .padding(.bottom, 20) .accessibilityIdentifier("welcome_back_text") - Text(AuthLocalization.SignIn.emailOrUsername) .font(Theme.Fonts.labelLarge) .foregroundColor(Theme.Colors.textPrimary) .accessibilityIdentifier("username_text") - TextField(AuthLocalization.SignIn.emailOrUsername, text: $email) + TextField("", text: $email) .font(Theme.Fonts.bodyLarge) - .foregroundColor(Theme.Colors.textPrimary) + .foregroundColor(Theme.Colors.textInputTextColor) .keyboardType(.emailAddress) .textContentType(.emailAddress) .autocapitalization(.none) .autocorrectionDisabled() .padding(.all, 14) .background( - Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputBackground) + Theme.InputFieldBackground( + placeHolder: AuthLocalization.SignIn.emailOrUsername, + text: email, + padding: 15 + ) ) .overlay( Theme.Shapes.textInputShape @@ -99,13 +101,16 @@ public struct SignInView: View { .foregroundColor(Theme.Colors.textPrimary) .padding(.top, 18) .accessibilityIdentifier("password_text") - SecureField(AuthLocalization.SignIn.password, text: $password) + SecureField("", text: $password) .font(Theme.Fonts.bodyLarge) - .foregroundColor(Theme.Colors.textPrimary) + .foregroundColor(Theme.Colors.textInputTextColor) .padding(.all, 14) .background( - Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputBackground) + Theme.InputFieldBackground( + placeHolder: AuthLocalization.SignIn.password, + text: password, + padding: 15 + ) ) .overlay( Theme.Shapes.textInputShape @@ -115,7 +120,7 @@ public struct SignInView: View { .accessibilityIdentifier("password_textfield") HStack { if !viewModel.config.features.startupScreenEnabled { - Button(CoreLocalization.SignIn.registerBtn) { + Button(CoreLocalization.register) { viewModel.router.showRegisterScreen(sourceScreen: viewModel.sourceScreen) } .foregroundColor(Theme.Colors.accentColor) @@ -129,7 +134,7 @@ public struct SignInView: View { viewModel.router.showForgotPasswordScreen() } .font(Theme.Fonts.bodyLarge) - .foregroundColor(Theme.Colors.accentXColor) + .foregroundColor(Theme.Colors.infoColor) .padding(.top, 0) .accessibilityIdentifier("forgot_password_button") } @@ -222,7 +227,7 @@ public struct SignInView: View { policy ) Text(.init(text)) - .tint(Theme.Colors.accentXColor) + .tint(Theme.Colors.infoColor) .foregroundStyle(Theme.Colors.textSecondaryLight) .font(Theme.Fonts.labelSmall) .padding(.top, viewModel.socialAuthEnabled ? 0 : 15) diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index 6dec70a19..fb9614ceb 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -40,7 +40,7 @@ public struct SignUpView: View { VStack(alignment: .center) { ZStack { HStack { - Text(CoreLocalization.SignIn.registerBtn) + Text(CoreLocalization.register) .titleSettings(color: Theme.Colors.loginNavigationText) .accessibilityIdentifier("register_text") } @@ -63,7 +63,7 @@ public struct SignUpView: View { ScrollView { VStack(alignment: .leading) { - Text(AuthLocalization.SignUp.title) + Text(CoreLocalization.register) .font(Theme.Fonts.displaySmall) .foregroundColor(Theme.Colors.textPrimary) .padding(.bottom, 4) @@ -73,7 +73,7 @@ public struct SignUpView: View { .foregroundColor(Theme.Colors.textPrimary) .padding(.bottom, 20) .accessibilityIdentifier("signup_subtitle_text") - + if viewModel.thirdPartyAuthSuccess { Text(AuthLocalization.SignUp.successSigninLabel) .font(Theme.Fonts.titleMedium) diff --git a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift index 256b60f97..a66562028 100644 --- a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift +++ b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift @@ -92,17 +92,20 @@ public struct ResetPasswordView: View { .font(Theme.Fonts.labelLarge) .foregroundColor(Theme.Colors.textPrimary) .accessibilityIdentifier("email_text") - TextField(AuthLocalization.SignIn.email, text: $email) + TextField("", text: $email) .font(Theme.Fonts.bodyLarge) - .foregroundColor(Theme.Colors.textPrimary) + .foregroundColor(Theme.Colors.textInputTextColor) .keyboardType(.emailAddress) .textContentType(.emailAddress) .autocapitalization(.none) .autocorrectionDisabled() .padding(.all, 14) .background( - Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputBackground) + Theme.InputFieldBackground( + placeHolder: AuthLocalization.SignIn.email, + text: email, + padding: 15 + ) ) .overlay( Theme.Shapes.textInputShape diff --git a/Authorization/Authorization/Presentation/Startup/StartupView.swift b/Authorization/Authorization/Presentation/Startup/StartupView.swift index f0ac99841..b8b3db370 100644 --- a/Authorization/Authorization/Presentation/Startup/StartupView.swift +++ b/Authorization/Authorization/Presentation/Startup/StartupView.swift @@ -55,8 +55,8 @@ public struct StartupView: View { Image(systemName: "magnifyingglass") .padding(.leading, 16) .padding(.top, 1) - .foregroundColor(Theme.Colors.textPrimary) - TextField(AuthLocalization.Startup.searchPlaceholder, text: $searchQuery, onCommit: { + .foregroundColor(Theme.Colors.textInputTextColor) + TextField("", text: $searchQuery, onCommit: { if searchQuery.isEmpty { return } viewModel.router.showDiscoveryScreen( searchQuery: searchQuery, @@ -68,7 +68,7 @@ public struct StartupView: View { .frame(minHeight: 50) .submitLabel(.search) .font(Theme.Fonts.bodyLarge) - .foregroundColor(Theme.Colors.textPrimary) + .foregroundColor(Theme.Colors.textInputTextColor) .accessibilityIdentifier("explore_courses_textfield") }.overlay( @@ -77,19 +77,22 @@ public struct StartupView: View { .fill(Theme.Colors.textInputStroke) ) .background( - Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputBackground) + Theme.InputFieldBackground( + placeHolder: AuthLocalization.Startup.searchPlaceholder, + text: searchQuery, + padding: 48 + ) ) Button { - viewModel.router.showDiscoveryScreen ( + viewModel.router.showDiscoveryScreen( searchQuery: searchQuery, sourceScreen: .startup ) } label: { Text(AuthLocalization.Startup.exploreAllCourses) .underline() - .foregroundColor(Theme.Colors.accentXColor) + .foregroundColor(Theme.Colors.infoColor) .font(Theme.Fonts.bodyLarge) } .padding(.top, isHorizontal ? 0 : 5) diff --git a/Authorization/Authorization/SwiftGen/Strings.swift b/Authorization/Authorization/SwiftGen/Strings.swift index 2a28a0db4..a977c2cae 100644 --- a/Authorization/Authorization/SwiftGen/Strings.swift +++ b/Authorization/Authorization/SwiftGen/Strings.swift @@ -69,8 +69,8 @@ public enum AuthLocalization { public static let logInTitle = AuthLocalization.tr("Localizable", "SIGN_IN.LOG_IN_TITLE", fallback: "Sign in") /// Password public static let password = AuthLocalization.tr("Localizable", "SIGN_IN.PASSWORD", fallback: "Password") - /// Welcome back! Please authorize to continue. - public static let welcomeBack = AuthLocalization.tr("Localizable", "SIGN_IN.WELCOME_BACK", fallback: "Welcome back! Please authorize to continue.") + /// Welcome back! Sign in to access your courses. + public static let welcomeBack = AuthLocalization.tr("Localizable", "SIGN_IN.WELCOME_BACK", fallback: "Welcome back! Sign in to access your courses.") } public enum SignUp { /// By creating an account, you agree to the [%@ End User License Agreement](%@) and [%@ Terms of Service and Honor Code](%@) and you acknowledge that %@ and each Member process your personal data inaccordance with the [Privacy Policy.](%@) @@ -87,14 +87,12 @@ public enum AuthLocalization { } /// Show optional Fields public static let showFields = AuthLocalization.tr("Localizable", "SIGN_UP.SHOW_FIELDS", fallback: "Show optional Fields") - /// Create new account. - public static let subtitle = AuthLocalization.tr("Localizable", "SIGN_UP.SUBTITLE", fallback: "Create new account.") + /// Create an account to start learning today! + public static let subtitle = AuthLocalization.tr("Localizable", "SIGN_UP.SUBTITLE", fallback: "Create an account to start learning today!") /// You've successfully signed in. public static let successSigninLabel = AuthLocalization.tr("Localizable", "SIGN_UP.SUCCESS_SIGNIN_LABEL", fallback: "You've successfully signed in.") /// We just need a little more information before you start learning. public static let successSigninSublabel = AuthLocalization.tr("Localizable", "SIGN_UP.SUCCESS_SIGNIN_SUBLABEL", fallback: "We just need a little more information before you start learning.") - /// Sign up - public static let title = AuthLocalization.tr("Localizable", "SIGN_UP.TITLE", fallback: "Sign up") } public enum Startup { /// Explore all courses diff --git a/Authorization/Authorization/en.lproj/Localizable.strings b/Authorization/Authorization/en.lproj/Localizable.strings index 6133ff62f..9b776c4b9 100644 --- a/Authorization/Authorization/en.lproj/Localizable.strings +++ b/Authorization/Authorization/en.lproj/Localizable.strings @@ -7,7 +7,7 @@ */ "SIGN_IN.LOG_IN_TITLE" = "Sign in"; -"SIGN_IN.WELCOME_BACK" = "Welcome back! Please authorize to continue."; +"SIGN_IN.WELCOME_BACK" = "Welcome back! Sign in to access your courses."; "SIGN_IN.EMAIL" = "Email"; "SIGN_IN.EMAIL_OR_USERNAME" = "Email or username"; "SIGN_IN.PASSWORD" = "Password"; @@ -22,8 +22,7 @@ accordance with the [Privacy Policy.](%@)"; "ERROR.INVALID_EMAIL_ADDRESS_OR_USERNAME" = "Invalid email or username"; "ERROR.DISABLED_ACCOUNT" = "Your account is disabled. Please contact customer support for assistance."; -"SIGN_UP.TITLE" = "Sign up"; -"SIGN_UP.SUBTITLE" = "Create new account."; +"SIGN_UP.SUBTITLE" = "Create an account to start learning today!"; "SIGN_UP.CREATE_ACCOUNT_BTN" = "Create account"; "SIGN_UP.HIDE_FIELDS" = "Hide optional Fields"; "SIGN_UP.SHOW_FIELDS" = "Show optional Fields"; diff --git a/Authorization/Authorization/uk.lproj/Localizable.strings b/Authorization/Authorization/uk.lproj/Localizable.strings index 3cd9772d4..e341b0ebd 100644 --- a/Authorization/Authorization/uk.lproj/Localizable.strings +++ b/Authorization/Authorization/uk.lproj/Localizable.strings @@ -7,7 +7,7 @@ */ "SIGN_IN.LOG_IN_TITLE" = "Увійти"; -"SIGN_IN.WELCOME_BACK" = "З поверненням! Авторизуйтесь, щоб продовжити."; +"SIGN_IN.WELCOME_BACK" = "Welcome back! Sign in to access your courses."; "SIGN_IN.EMAIL" = "Пошта"; "SIGN_IN.PASSWORD" = "Пароль"; "SIGN_IN.FORGOT_PASS_BTN" = "Забули пароль?"; @@ -19,8 +19,7 @@ accordance with the [Privacy Policy.](%@)"; "ERROR.ACCOUNT_NOT_REGISTERED" = "This %@ account is not linked with any %@ account. Please register."; "ERROR.DISABLED_ACCOUNT" = "Your account is disabled. Please contact customer support for assistance."; -"SIGN_UP.TITLE" = "Зареєструватись"; -"SIGN_UP.SUBTITLE" = "Cтворити новий акаунт."; +"SIGN_UP.SUBTITLE" = "Create an account to start learning today!"; "SIGN_UP.CREATE_ACCOUNT_BTN" = "Створити акаунт"; "SIGN_UP.HIDE_FIELDS" = "Приховати необовʼязкові поля"; "SIGN_UP.SHOW_FIELDS" = "Показати необовʼязкові поля"; diff --git a/Core/Core/SwiftGen/Strings.swift b/Core/Core/SwiftGen/Strings.swift index d47476385..a5782d497 100644 --- a/Core/Core/SwiftGen/Strings.swift +++ b/Core/Core/SwiftGen/Strings.swift @@ -14,6 +14,8 @@ public enum CoreLocalization { public static let done = CoreLocalization.tr("Localizable", "DONE", fallback: "Done") /// View in Safari public static let openInBrowser = CoreLocalization.tr("Localizable", "OPEN_IN_BROWSER", fallback: "View in Safari") + /// Register + public static let register = CoreLocalization.tr("Localizable", "REGISTER", fallback: "Register") /// The user canceled the sign-in flow. public static let socialSignCanceled = CoreLocalization.tr("Localizable", "SOCIAL_SIGN_CANCELED", fallback: "The user canceled the sign-in flow.") /// Tomorrow @@ -208,8 +210,6 @@ public enum CoreLocalization { public enum SignIn { /// Sign in public static let logInBtn = CoreLocalization.tr("Localizable", "SIGN_IN.LOG_IN_BTN", fallback: "Sign in") - /// Register - public static let registerBtn = CoreLocalization.tr("Localizable", "SIGN_IN.REGISTER_BTN", fallback: "Register") } public enum View { public enum Snackbar { diff --git a/Core/Core/View/Base/FlexibleKeyboardInputView.swift b/Core/Core/View/Base/FlexibleKeyboardInputView.swift index 60dfc5085..48a1119f5 100644 --- a/Core/Core/View/Base/FlexibleKeyboardInputView.swift +++ b/Core/Core/View/Base/FlexibleKeyboardInputView.swift @@ -53,18 +53,15 @@ public struct FlexibleKeyboardInputView: View { .overlay( TextEditor(text: $commentText) .padding(.horizontal, 8) - .foregroundColor(Theme.Colors.textPrimary) + .foregroundColor(Theme.Colors.textInputTextColor) .hideScrollContentBackground() .frame(maxHeight: commentSize) .background( - ZStack(alignment: .leading) { - Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputBackground) - Text(commentText.count == 0 ? hint : "") - .foregroundColor(Theme.Colors.textSecondary) - .font(Theme.Fonts.labelLarge) - .padding(.leading, 14) - } + Theme.InputFieldBackground( + placeHolder: commentText.count == 0 ? hint : "", + text: commentText, + padding: 14 + ) ) .overlay( Theme.Shapes.textInputShape diff --git a/Core/Core/View/Base/LogistrationBottomView.swift b/Core/Core/View/Base/LogistrationBottomView.swift index 776ab12d2..fca95cc04 100644 --- a/Core/Core/View/Base/LogistrationBottomView.swift +++ b/Core/Core/View/Base/LogistrationBottomView.swift @@ -38,7 +38,7 @@ public struct LogistrationBottomView: View { public var body: some View { VStack(alignment: .leading) { HStack(spacing: 24) { - StyledButton(CoreLocalization.SignIn.registerBtn) { + StyledButton(CoreLocalization.register) { action(.register) } .accessibilityIdentifier("logistration_register_button") @@ -48,7 +48,7 @@ public struct LogistrationBottomView: View { action: { action(.signIn) }, - color: Theme.Colors.white, + color: Theme.Colors.background, textColor: Theme.Colors.secondaryButtonTextColor, borderColor: Theme.Colors.secondaryButtonBorderColor ) diff --git a/Core/Core/View/Base/PickerMenu.swift b/Core/Core/View/Base/PickerMenu.swift index 2066a8aa0..0de023381 100644 --- a/Core/Core/View/Base/PickerMenu.swift +++ b/Core/Core/View/Base/PickerMenu.swift @@ -83,10 +83,20 @@ public struct PickerMenu: View { .foregroundColor(Theme.Colors.textPrimary) .accessibilityIdentifier("picker_title_text") .font(Theme.Fonts.bodyMedium) - TextField(CoreLocalization.Picker.search, text: $search) + TextField("", text: $search) .padding(.all, 8) .font(Theme.Fonts.bodySmall) - .background(Theme.Colors.textInputStroke.cornerRadius(6)) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(Theme.Colors.textInputStroke) + ) + .background( + Theme.InputFieldBackground( + placeHolder: CoreLocalization.Picker.search, + text: search + ) + ) .accessibilityIdentifier("picker_search_textfield") Picker("", selection: $selectedItem) { ForEach(filteredItems, id: \.self) { item in @@ -104,7 +114,7 @@ public struct PickerMenu: View { : .infinity) .padding() - .background(Theme.Colors.textInputBackground.cornerRadius(16)) + .background(Theme.Colors.background.cornerRadius(16)) .padding(.horizontal, 16) .onChange(of: search, perform: { _ in if let first = filteredItems.first { @@ -124,7 +134,7 @@ public struct PickerMenu: View { ? ipadPickerWidth : .infinity) .padding() - .background(Theme.Colors.textInputBackground.cornerRadius(16)) + .background(Theme.Colors.background.cornerRadius(16)) .padding(.horizontal, 16) } .padding(.bottom, 4) diff --git a/Core/Core/View/Base/PickerView.swift b/Core/Core/View/Base/PickerView.swift index 868a5e600..d0655ddb4 100644 --- a/Core/Core/View/Base/PickerView.swift +++ b/Core/Core/View/Base/PickerView.swift @@ -54,7 +54,7 @@ public struct PickerView: View { }) .accessibilityIdentifier("\(config.field.name)_picker_button") }.padding(.all, 14) - .foregroundColor(Theme.Colors.textPrimary) + .foregroundColor(Theme.Colors.textInputTextColor) .background( Theme.Shapes.textInputShape .fill(Theme.Colors.textInputBackground) diff --git a/Core/Core/View/Base/RegistrationTextField.swift b/Core/Core/View/Base/RegistrationTextField.swift index a6751451b..9ed039763 100644 --- a/Core/Core/View/Base/RegistrationTextField.swift +++ b/Core/Core/View/Base/RegistrationTextField.swift @@ -43,9 +43,10 @@ public struct RegistrationTextField: View { if isTextArea { TextEditor(text: $config.text) .font(Theme.Fonts.bodyMedium) - .foregroundColor(Theme.Colors.textPrimary) + .foregroundColor(Theme.Colors.textInputTextColor) .padding(.horizontal, 12) .padding(.vertical, 4) + .foregroundColor(Theme.Colors.textInputTextColor) .frame(height: 100) .hideScrollContentBackground() .background( @@ -90,7 +91,7 @@ public struct RegistrationTextField: View { } else { TextField(placeholder, text: $config.text) .font(Theme.Fonts.bodyLarge) - .foregroundColor(Theme.Colors.textPrimary) + .foregroundColor(Theme.Colors.textInputTextColor) .keyboardType(keyboardType) .textContentType(textContentType) .autocapitalization(.none) diff --git a/Core/Core/en.lproj/Localizable.strings b/Core/Core/en.lproj/Localizable.strings index 8d868b239..27196487a 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -108,7 +108,7 @@ "SOCIAL_SIGN_CANCELED" = "The user canceled the sign-in flow."; "SIGN_IN.LOG_IN_BTN" = "Sign in"; -"SIGN_IN.REGISTER_BTN" = "Register"; +"REGISTER" = "Register"; "TOMORROW" = "Tomorrow"; "YESTERDAY" = "Yesterday"; diff --git a/Core/Core/uk.lproj/Localizable.strings b/Core/Core/uk.lproj/Localizable.strings index ce8cc2ac5..1da07ab04 100644 --- a/Core/Core/uk.lproj/Localizable.strings +++ b/Core/Core/uk.lproj/Localizable.strings @@ -107,7 +107,7 @@ "AUTHORIZATION_FAILED" = "Authorization failed."; "SIGN_IN.LOG_IN_BTN" = "Увійти"; -"SIGN_IN.REGISTER_BTN" = "Реєстрація"; +"REGISTER" = "Реєстрація"; "TOMORROW" = "Tomorrow"; "YESTERDAY" = "Yesterday"; diff --git a/Course/Course/Presentation/Unit/Subviews/LessonLineProgressView.swift b/Course/Course/Presentation/Unit/Subviews/LessonLineProgressView.swift index 17ae05de2..b569a4f65 100644 --- a/Course/Course/Presentation/Unit/Subviews/LessonLineProgressView.swift +++ b/Course/Course/Presentation/Unit/Subviews/LessonLineProgressView.swift @@ -20,7 +20,7 @@ struct LessonLineProgressView: View { var body: some View { ZStack(alignment: .bottom) { Theme.Colors.background - HStack(spacing: 3) { + HStack(spacing: 8) { let vertical = viewModel.verticals[viewModel.verticalIndex] let data = Array(vertical.childs.enumerated()) ForEach(data, id: \.offset) { index, item in diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift index 7f123a28a..065147d95 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift @@ -60,7 +60,7 @@ public struct SearchView: View { .onAppear { self.focused = true } - .foregroundColor(Theme.Colors.textPrimary) + .foregroundColor(Theme.Colors.textInputTextColor) .font(Theme.Fonts.bodyLarge) .accessibilityIdentifier("search_textfields") Spacer() diff --git a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift index 0d031e983..7c676df8d 100644 --- a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift +++ b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift @@ -86,10 +86,12 @@ public struct CreateNewThreadView: View { Text(viewModel.allTopics.first(where: { $0.id == viewModel.selectedTopic })?.name ?? "") .font(Theme.Fonts.labelLarge) - .foregroundColor(Theme.Colors.textPrimary) + .foregroundColor(Theme.Colors.textInputTextColor) .frame(height: 40, alignment: .leading) Spacer() Image(systemName: "chevron.down") + .renderingMode(.template) + .foregroundColor(Theme.Colors.textInputTextColor) }.padding(.horizontal, 14) .accentColor(Theme.Colors.textPrimary) .background(Theme.Shapes.textInputShape @@ -112,7 +114,7 @@ public struct CreateNewThreadView: View { }.padding(.top, 16) TextField("", text: $postTitle) .font(Theme.Fonts.bodyLarge) - .foregroundColor(Theme.Colors.textPrimary) + .foregroundColor(Theme.Colors.textInputTextColor) .padding(14) .frame(height: 40) .background( @@ -135,7 +137,7 @@ public struct CreateNewThreadView: View { }.padding(.top, 16) TextEditor(text: $postBody) .font(Theme.Fonts.bodyMedium) - .foregroundColor(Theme.Colors.textPrimary) + .foregroundColor(Theme.Colors.textInputTextColor) .padding(.horizontal, 10) .padding(.vertical, 10) .frame(height: 200) diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift index 360df3d78..de306714b 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift @@ -33,19 +33,14 @@ public struct DiscussionSearchTopicsView: View { HStack(spacing: 11) { Image(systemName: "magnifyingglass") - .foregroundColor(Theme.Colors.textPrimary) + .foregroundColor(Theme.Colors.textInputTextColor) .padding(.leading, 16) .padding(.top, -1) .foregroundColor( - viewModel.isSearchActive - ? Theme.Colors.accentColor - : Theme.Colors.textPrimary + Theme.Colors.textInputTextColor ) - TextField( - !viewModel.isSearchActive - ? DiscussionLocalization.search - : "", + TextField("", text: $viewModel.searchText, onEditingChanged: { editing in viewModel.isSearchActive = editing @@ -54,7 +49,7 @@ public struct DiscussionSearchTopicsView: View { .onAppear { self.focused = true } - .foregroundColor(Theme.Colors.textPrimary) + .foregroundColor(Theme.Colors.textInputTextColor) .font(Theme.Fonts.bodyMedium) Spacer() if !viewModel.searchText.trimmingCharacters(in: .whitespaces).isEmpty { @@ -68,19 +63,21 @@ public struct DiscussionSearchTopicsView: View { .foregroundColor(Theme.Colors.styledButtonText) } } - // .padding(.top, -7) .frame(minHeight: 48) .background( - Theme.Shapes.textInputShape - .fill(viewModel.isSearchActive - ? Theme.Colors.textInputBackground - : Theme.Colors.textInputUnfocusedBackground) + Theme.InputFieldBackground( + placeHolder: !viewModel.isSearchActive + ? DiscussionLocalization.search + : "", + text: viewModel.searchText, + padding: 48 + ) ) .overlay( Theme.Shapes.textInputShape .stroke(lineWidth: 1) .fill(viewModel.isSearchActive - ? Theme.Colors.accentColor + ? Theme.Colors.textInputTextColor : Theme.Colors.textInputUnfocusedStroke) ) .frameLimit(width: proxy.size.width) diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift index c959ed2a9..b6fb46c31 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift @@ -29,18 +29,18 @@ public struct DiscussionTopicsView: View { // MARK: - Search fake field HStack(spacing: 11) { Image(systemName: "magnifyingglass") - .foregroundColor(Theme.Colors.textSecondary) + .foregroundColor(Theme.Colors.textInputTextColor) .padding(.leading, 16) .padding(.top, 1) Text(DiscussionLocalization.Topics.search) - .foregroundColor(Theme.Colors.textSecondary) + .foregroundColor(Theme.Colors.textInputTextColor) .font(Theme.Fonts.bodyMedium) Spacer() } .frame(minHeight: 48) .background( Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputUnfocusedBackground) + .fill(Theme.Colors.textInputBackground) ) .overlay( Theme.Shapes.textInputShape diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift index 105715367..3f6fc6fe1 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift @@ -39,7 +39,7 @@ public struct DeleteAccountView: View { Text(ProfileLocalization.DeleteAccount.areYouSure) .foregroundColor(Theme.Colors.navigationBarTintColor) + Text(ProfileLocalization.DeleteAccount.wantToDelete) - .foregroundColor(Theme.Colors.alert) + .foregroundColor(Theme.Colors.irreversibleAlert) } .accessibilityIdentifier("are_you_sure_text") @@ -63,18 +63,21 @@ public struct DeleteAccountView: View { .accessibilityIdentifier("password_text") HStack(spacing: 11) { - SecureField(ProfileLocalization.DeleteAccount.passwordDescription, + SecureField("", text: $viewModel.password) .font(Theme.Fonts.labelLarge) - .foregroundColor(Theme.Colors.textPrimary) + .foregroundColor(Theme.Colors.textInputTextColor) .accessibilityIdentifier("password_textfield") } .padding(.horizontal, 14) .frame(minHeight: 48) .frame(maxWidth: .infinity) .background( - Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputBackground) + Theme.InputFieldBackground( + placeHolder: ProfileLocalization.DeleteAccount.passwordDescription, + text: viewModel.password, + padding: 15 + ) ) .overlay( Theme.Shapes.textInputShape @@ -84,7 +87,7 @@ public struct DeleteAccountView: View { Text(viewModel.incorrectPassword ? ProfileLocalization.DeleteAccount.incorrectPassword : " ") - .foregroundColor(Theme.Colors.alert) + .foregroundColor(Theme.Colors.irreversibleAlert) .font(Theme.Fonts.labelLarge) .multilineTextAlignment(.leading) .padding(.top, 0) @@ -111,8 +114,8 @@ public struct DeleteAccountView: View { } }, color: .clear, - textColor: Theme.Colors.alert, - borderColor: Theme.Colors.alert, + textColor: Theme.Colors.irreversibleAlert, + borderColor: Theme.Colors.irreversibleAlert, isActive: viewModel.password.count >= 2 ) .padding(.top, 18) diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift index 8909ddfc6..86927376f 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift @@ -86,7 +86,7 @@ public struct EditProfileView: View { .accessibilityIdentifier("about_text") TextEditor(text: $viewModel.profileChanges.shortBiography) .font(Theme.Fonts.bodyMedium) - .foregroundColor(Theme.Colors.textPrimary) + .foregroundColor(Theme.Colors.textInputTextColor) .padding(.horizontal, 12) .padding(.vertical, 4) .frame(height: 200) diff --git a/Theme/Theme/Assets.xcassets/Colors/InfoColor.colorset copy/Contents.json b/Theme/Theme/Assets.xcassets/Colors/InfoColor.colorset copy/Contents.json new file mode 100644 index 000000000..00d59cb46 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/InfoColor.colorset copy/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.408", + "red" : "0.235" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.976", + "green" : "0.471", + "red" : "0.329" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/Colors/InfoColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/InfoColor.colorset/Contents.json new file mode 100644 index 000000000..00d59cb46 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/InfoColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "0.408", + "red" : "0.235" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.976", + "green" : "0.471", + "red" : "0.329" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/Colors/IrreversibleAlert.colorset copy/Contents.json b/Theme/Theme/Assets.xcassets/Colors/IrreversibleAlert.colorset copy/Contents.json new file mode 100644 index 000000000..14e0c379b --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/IrreversibleAlert.colorset copy/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.443", + "green" : "0.239", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.443", + "green" : "0.239", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/Colors/IrreversibleAlert.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/IrreversibleAlert.colorset/Contents.json new file mode 100644 index 000000000..14e0c379b --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/IrreversibleAlert.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.443", + "green" : "0.239", + "red" : "1.000" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.443", + "green" : "0.239", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/Colors/TextInput/TextInputPlaceholderColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/TextInput/TextInputPlaceholderColor.colorset/Contents.json new file mode 100644 index 000000000..93691d3e8 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/TextInput/TextInputPlaceholderColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.733", + "green" : "0.647", + "red" : "0.592" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.624", + "green" : "0.533", + "red" : "0.475" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/Colors/TextInput/TextInputTextColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/TextInput/TextInputTextColor.colorset/Contents.json new file mode 100644 index 000000000..a3f0c654a --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/TextInput/TextInputTextColor.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0.184", + "green" : "0.129", + "red" : "0.098" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "1.000", + "green" : "1.000", + "red" : "1.000" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/SwiftGen/ThemeAssets.swift b/Theme/Theme/SwiftGen/ThemeAssets.swift index 4eeb5d2c3..c6204d609 100644 --- a/Theme/Theme/SwiftGen/ThemeAssets.swift +++ b/Theme/Theme/SwiftGen/ThemeAssets.swift @@ -43,6 +43,8 @@ public enum ThemeAssets { public static let todayTimelineColor = ColorAsset(name: "TodayTimelineColor") public static let upcomingTimelineColor = ColorAsset(name: "UpcomingTimelineColor") public static let pastDueTimelineColor = ColorAsset(name: "pastDueTimelineColor") + public static let infoColor = ColorAsset(name: "InfoColor") + public static let irreversibleAlert = ColorAsset(name: "IrreversibleAlert") public static let loginBackground = ColorAsset(name: "LoginBackground") public static let loginNavigationText = ColorAsset(name: "LoginNavigationText") public static let primaryButtonTextColor = ColorAsset(name: "PrimaryButtonTextColor") @@ -64,7 +66,9 @@ public enum ThemeAssets { public static let textSecondary = ColorAsset(name: "TextSecondary") public static let textSecondaryLight = ColorAsset(name: "TextSecondaryLight") public static let textInputBackground = ColorAsset(name: "TextInputBackground") + public static let textInputPlaceholderColor = ColorAsset(name: "TextInputPlaceholderColor") public static let textInputStroke = ColorAsset(name: "TextInputStroke") + public static let textInputTextColor = ColorAsset(name: "TextInputTextColor") public static let textInputUnfocusedBackground = ColorAsset(name: "TextInputUnfocusedBackground") public static let textInputUnfocusedStroke = ColorAsset(name: "TextInputUnfocusedStroke") public static let toggleSwitchColor = ColorAsset(name: "ToggleSwitchColor") diff --git a/Theme/Theme/Theme.swift b/Theme/Theme/Theme.swift index 473eb9c0a..fba4e1b06 100644 --- a/Theme/Theme/Theme.swift +++ b/Theme/Theme/Theme.swift @@ -60,6 +60,10 @@ public struct Theme { public private(set) static var tabbarColor = ThemeAssets.tabbarColor.swiftUIColor public private(set) static var primaryButtonTextColor = ThemeAssets.primaryButtonTextColor.swiftUIColor public private(set) static var toggleSwitchColor = ThemeAssets.toggleSwitchColor.swiftUIColor + public private(set) static var textInputTextColor = ThemeAssets.textInputTextColor.swiftUIColor + public private(set) static var textInputPlaceholderColor = ThemeAssets.textInputPlaceholderColor.swiftUIColor + public private(set) static var infoColor = ThemeAssets.infoColor.swiftUIColor + public private(set) static var irreversibleAlert = ThemeAssets.irreversibleAlert.swiftUIColor public static func update( accentColor: Color = ThemeAssets.accentColor.swiftUIColor, @@ -102,7 +106,11 @@ public struct Theme { success: Color = ThemeAssets.success.swiftUIColor, tabbarColor: Color = ThemeAssets.tabbarColor.swiftUIColor, primaryButtonTextColor: Color = ThemeAssets.primaryButtonTextColor.swiftUIColor, - toggleSwitchColor: Color = ThemeAssets.toggleSwitchColor.swiftUIColor + toggleSwitchColor: Color = ThemeAssets.toggleSwitchColor.swiftUIColor, + textInputTextColor: Color = ThemeAssets.textInputTextColor.swiftUIColor, + textInputPlaceholderColor: Color = ThemeAssets.textInputPlaceholderColor.swiftUIColor, + infoColor: Color = ThemeAssets.infoColor.swiftUIColor, + irreversibleAlert: Color = ThemeAssets.irreversibleAlert.swiftUIColor ) { self.accentColor = accentColor self.accentXColor = accentXColor @@ -145,6 +153,10 @@ public struct Theme { self.tabbarColor = tabbarColor self.primaryButtonTextColor = primaryButtonTextColor self.toggleSwitchColor = toggleSwitchColor + self.textInputTextColor = textInputTextColor + self.textInputPlaceholderColor = textInputPlaceholderColor + self.infoColor = infoColor + self.irreversibleAlert = irreversibleAlert } } @@ -251,6 +263,40 @@ public struct Theme { public static let snackbarMessageLongTimeout: TimeInterval = 5 } + public struct InputFieldBackground: View { + public let placeHolder: String + public let text: String + public let color: Color + public let padding: CGFloat + public let font: Font + + public init( + placeHolder: String, + text: String, + color: Color = Theme.Colors.textInputPlaceholderColor, + font: Font = Theme.Fonts.bodyLarge, + padding: CGFloat = 8 + ) { + self.placeHolder = placeHolder + self.color = color + self.text = text + self.padding = padding + self.font = font + } + + public var body: some View { + ZStack(alignment: .leading) { + Theme.Shapes.textInputShape + .fill(Theme.Colors.textInputBackground) + if text.count == 0 { + Text(placeHolder) + .foregroundColor(color) + .padding(.leading, padding) + .font(font) + } + } + } + } } public extension Theme.Fonts { From 47a97570fdddfc73a103a1eb298a794c6a9ce642 Mon Sep 17 00:00:00 2001 From: Omar Al-Ithawi Date: Fri, 29 Mar 2024 14:47:12 +0300 Subject: [PATCH 109/136] docs: design for atlas pull and translations management (#367) --- docs/0002-atlas-translations-management.rst | 190 ++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 docs/0002-atlas-translations-management.rst diff --git a/docs/0002-atlas-translations-management.rst b/docs/0002-atlas-translations-management.rst new file mode 100644 index 000000000..44464f513 --- /dev/null +++ b/docs/0002-atlas-translations-management.rst @@ -0,0 +1,190 @@ +Atlas Translations Management Design +#################################### + +Date: 25 March 2024 + +Status +****** +Accepted + +Context +******* + +Open edX microservices and micro-frontends use the processes outlined in `OEP-58`_ to pull the latest +translations from the `openedx-translations repository`_. + +The main changes that `OEP-58`_ introduced to the Open edX project are the following: + +- Extract the English translation source entries and commit them into the `openedx-translations repository`_ +- The `GitHub Transifex app`_ will automatically commit the translations to the `openedx-translations repository`_ +- Provide a command that utilizes the `atlas`_ tool to pull translations in both development and web server image builds + +Microservices, micro-frontends, XBlocks and plugins use as few translation files as possible to make it easier for Translators +to locate and translate relevant resources. + +`edx-platform`_ translation strings used to be split between 7 files, but this has been deprecated in favor of +``edx-platform.po`` and ``edx-platform-js.po`` files. +More details are available in the `edx-platform resources combination decision document`_. + +`openedx-app-ios`_ uses a modular architecture. Each module has its own i18n +localizable strings file. This is useful for Developer Experience, but can be harmful +to the Translator Experience. Translators often are unaware of the engineering +architecture and would like to be able to quickly search for strings regardless of +their location in the code. + +Decision +******** + +Combine English ``Localizable.strings`` files from mobile app modules before pushing them to the `openedx-translations repository`_. +split back the translated files into the modules after pulling from the `openedx-translations repository`_ via +`atlas`_. + +The `OEP-58`_ workflow for mobile apps will proceed as follows: + +* The `openedx-translations repository`_ runs a daily cronjob to collect strings from the `openedx-app-ios`_ ``main`` branch. + + * The `extract-translation-source-files.yml`_ workflow will clone the `openedx-app-ios`_ repo. + + * The `extract-translation-source-files.yml`_ workflow will run `openedx-app-ios`_'s ``make combine_translations``. + + * ``make combine_translations`` combines English ``Localizable.strings`` files from the mobile app modules into a single ``I18N/I18N/en.lproj/Localizable.strings`` file. + + * The ``I18N/I18N/en.lproj/Localizable.strings`` file is committed in the `openedx-translations repository`_. + + * The `GitHub Transifex app`_ will fetch the updated English source ``.strings`` files from `openedx-translations repository`_ + + * Translators can translate the strings in the `Transifex openedx-translations project`_ + + * The `GitHub Transifex app`_ syncs the translated strings into the `openedx-translations repository`_ + +* Developers run ``make pull_translations`` to pull the translations from the `openedx-translations repository`_ + + * ``make pull_translations`` runs `atlas pull`_ to pull the translations + + * The translations will be pulled as a single file for each language e.g. ``I18N/I18N/ar.lproj/Localizable.strings`` + + * Then ``make pull_translations`` will run ``python scripts/split_translation_files.py`` to split + the translations by module and place them in their corresponding directories + e.g. ``Course/Course/ar.lproj/Localizable.strings`` + +Notable changes +=============== + +- Translation files are no longer committed directly to the mobie app repositories (`openedx-app-ios`_ / `openedx-app-android`_) +- Before releasing to App Store, developers need to run a new ``make pull_translations`` command + +String file combination and splitting process +============================================= +This is a new process that's being introduced to have the best combination +of Developer Experience and Translator Experience. + +The best experience for Translators requires combining source strings into as few transifex resources as possible. +The best experience for Engineers requires splitting translation source files to fit within the modular architecture. + +Combining the files during ``combine_translations`` +--------------------------------------------------- + +Combining the string files to aid Translator Experience in the following manner: + +Suppose we have two modules with one Localizable.strings file in each:: + + Course/Course/en.lproj/Localizable.strings + "COURSE_CONTAINER.VIDEOS" = "Videos"; + "COURSE_CONTAINER.DATES" = "Dates"; + "COURSE_CONTAINER.DISCUSSIONS" = "Discussions"; + "COURSE_CONTAINER.HANDOUTS" = "Handouts"; + "COURSE_CONTAINER.HANDOUTS_IN_DEVELOPING" = "Handouts In developing"; + + "HANDOUTS_CELL_HANDOUTS.TITLE" = "Handouts"; + "HANDOUTS_CELL_ANNOUNCEMENTS.TITLE" = "Announcements"; + + Dashboard/Dashboard/en.lproj/Localizable.strings: + "TITLE" = "Dashboard"; + "HEADER.COURSES" = "Courses"; + "HEADER.WELCOME_BACK" = "Welcome back. Let's keep learning."; + + "EMPTY.TITLE" = "It's empty"; + "EMPTY.SUBTITLE" = "You are not enrolled in any courses yet."; + +This combine translations script will collect the strings, remove unneeded comments and combine them in a temporary +file while prefixing each entry with its module name. This will make splitting clear and +avoid collision in string IDs:: + + I18N/I18N/en.lproj/Localizable.strings + "COURSE.COURSE_CONTAINER.VIDEOS" = "Videos"; + "COURSE.COURSE_CONTAINER.DATES" = "Dates"; + "COURSE.COURSE_CONTAINER.DISCUSSIONS" = "Discussions"; + "COURSE.COURSE_CONTAINER.HANDOUTS" = "Handouts"; + "COURSE.COURSE_CONTAINER.HANDOUTS_IN_DEVELOPING" = "Handouts In developing"; + "COURSE.HANDOUTS_CELL_HANDOUTS.TITLE" = "Handouts"; + "COURSE.HANDOUTS_CELL_ANNOUNCEMENTS.TITLE" = "Announcements"; + "DASHBOARD.TITLE" = "Dashboard"; + "DASHBOARD.HEADER.COURSES" = "Courses"; + "DASHBOARD.HEADER.WELCOME_BACK" = "Welcome back. Let's keep learning."; + "DASHBOARD.EMPTY.TITLE" = "It's empty"; + "DASHBOARD.EMPTY.SUBTITLE" = "You are not enrolled in any courses yet."; + +This combined file will be pushed to the `openedx-translations repository`_ as described in `OEP-58`_. + +This process happens entirely on the CI server (GitHub in this case) after each pull request merge without developer +intervention. + +Splitting the files after ``pull_translations`` +----------------------------------------------- + +After pulling the translations from the `openedx-translations repository`_ via `atlas pull`_, there will be a single +strings file for each language: + +.. code:: + + I18N/I18N/uk.lproj/Localizable.strings + I18N/I18N/ar.lproj/Localizable.strings + I18N/I18N/fr.lproj/Localizable.strings + I18N/I18N/es-419.lproj/Localizable.strings + +- The script will run through each module's ``en.lproj/Localizable.strings`` +- Identify which entries in the app strings file are translated in the e.g. ``I18N/I18N/ar.lproj/Localizable.strings`` file. +- Create module strings file in the each module and put the strings that exists in the ``en.lproj/Localizable.strings`` file +- The automatic module name prefix that ``combine_translations`` script has added is removed + +.. code:: + + Course/Course/uk.lproj/Localizable.strings + Course/Course/ar.lproj/Localizable.strings + Course/Course/fr.lproj/Localizable.strings + Course/Course/es-419.lproj/Localizable.strings + ... + Dashboard/Dashboard/uk.lproj/Localizable.strings + Dashboard/Dashboard/ar.lproj/Localizable.strings + Dashboard/Dashboard/fr.lproj/Localizable.strings + Dashboard/Dashboard/es-419.lproj/Localizable.strings + +This script should ensure that every entry in the source English file, should have an entry in the +translated files even if it has no translations. This will ensure app builds don't fail. + + +Python language for scripting +============================= +Python will be used in scripting the pull/push when needed. +This is in-line with the Theming tooling which is has been written in Python. + + +Alternatives +************ +- Writing scripts in native languages such as Kotlin and Swift has been dismissed as per core team request. +- Pushing multiple strings file resources for each mobile app to the `openedx-translations repository`_ is + dismissed to avoid having too many resources per mobile app in the Transifex project. +- Combining the strings files without a prefix is dismissed because it needs a dedicated validation script which + could confuse the community contributors by adding a new rule to ensure no duplicate strings are found. + +.. _OEP-58: https://docs.openedx.org/en/latest/developers/concepts/oep58.html +.. _openedx-translations repository: https://github.com/openedx/openedx-translations +.. _edx-platform: https://github.com/openedx/edx-platform +.. _atlas: https://github.com/openedx/openedx-atlas +.. _atlas pull: https://github.com/openedx/openedx-atlas?tab=readme-ov-file#usage +.. _edx-platform resources combination decision document: https://github.com/openedx/edx-platform/blob/master/docs/decisions/0018-standarize-django-po-files.rst +.. _GitHub Transifex app: https://github.com/apps/transifex-integration +.. _openedx-app-android: https://github.com/openedx/openedx-app-android +.. _openedx-app-ios: https://github.com/openedx/openedx-app-ios +.. _extract-translation-source-files.yml: https://github.com/openedx/openedx-translations/blob/2566e0c9a30d033e5dd8d05d4c12601c8e37b4ef/.github/workflows/extract-translation-source-files.yml +.. _Transifex openedx-translations project: https://app.transifex.com/open-edx/openedx-translations/content/ From b6c466c140db49e7278d3172ac26627bf0a237f8 Mon Sep 17 00:00:00 2001 From: Eugene Yatsenko Date: Fri, 29 Mar 2024 14:56:13 +0100 Subject: [PATCH 110/136] chore: hide buttons --- .../Comments/Responses/ResponsesView.swift | 30 +++--- .../Comments/Thread/ThreadView.swift | 29 +++--- .../Presentation/Posts/PostsView.swift | 93 ++++++++++--------- 3 files changed, 78 insertions(+), 74 deletions(-) diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index d1fc98fd4..11f559a09 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -156,23 +156,23 @@ public struct ResponsesView: View { } .frameLimit(width: proxy.size.width) } - - FlexibleKeyboardInputView( - hint: DiscussionLocalization.Response.addComment, - sendText: { commentText in - if let threadID = viewModel.postComments?.threadID { - Task { - await viewModel.postComment( - threadID: threadID, - rawBody: commentText, - parentID: commentID - ) + if !(parentComment.closed || viewModel.isBlackedOut) { + FlexibleKeyboardInputView( + hint: DiscussionLocalization.Response.addComment, + sendText: { commentText in + if let threadID = viewModel.postComments?.threadID { + Task { + await viewModel.postComment( + threadID: threadID, + rawBody: commentText, + parentID: commentID + ) + } } } - } - ) - .ignoresSafeArea(.all, edges: .horizontal) - .disabled(parentComment.closed || viewModel.isBlackedOut) + ) + .ignoresSafeArea(.all, edges: .horizontal) + } } } .onReceive(viewModel.addPostSubject, perform: { newComment in diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index 299f00fb2..075685d92 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -154,22 +154,23 @@ public struct ThreadView: View { } .frameLimit(width: proxy.size.width) } - FlexibleKeyboardInputView( - hint: DiscussionLocalization.Thread.addResponse, - sendText: { commentText in - if let threadID = viewModel.postComments?.threadID { - Task { - await viewModel.postComment( - threadID: threadID, - rawBody: commentText, - parentID: viewModel.postComments?.parentID - ) + if !(thread.closed || viewModel.isBlackedOut) { + FlexibleKeyboardInputView( + hint: DiscussionLocalization.Thread.addResponse, + sendText: { commentText in + if let threadID = viewModel.postComments?.threadID { + Task { + await viewModel.postComment( + threadID: threadID, + rawBody: commentText, + parentID: viewModel.postComments?.parentID + ) + } } } - } - ) - .ignoresSafeArea(.all, edges: .horizontal) - .disabled(thread.closed || viewModel.isBlackedOut) + ) + .ignoresSafeArea(.all, edges: .horizontal) + } } .onReceive(viewModel.addPostSubject, perform: { newComment in guard let newComment else { return } diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index c8feb7cdc..9664d1f19 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -119,30 +119,31 @@ public struct PostsView: View { .font(Theme.Fonts.titleLarge) .foregroundColor(Theme.Colors.textPrimary) Spacer() - Button(action: { - router.createNewThread( - courseID: courseID, - selectedTopic: currentBlockID, - onPostCreated: { - reloadPage(onSuccess: { - withAnimation { - scroll.scrollTo(1) - } + if !(viewModel.isBlackedOut ?? false) { + Button(action: { + router.createNewThread( + courseID: courseID, + selectedTopic: currentBlockID, + onPostCreated: { + reloadPage(onSuccess: { + withAnimation { + scroll.scrollTo(1) + } + }) }) - }) - }, label: { - VStack { - CoreAssets.addComment.swiftUIImage - .font(Theme.Fonts.labelLarge) - .padding(6) - } - .foregroundColor(Theme.Colors.white) - .background( - Circle() - .foregroundColor(Theme.Colors.accentButtonColor) - ) - }) - .disabled(viewModel.isBlackedOut ?? false) + }, label: { + VStack { + CoreAssets.addComment.swiftUIImage + .font(Theme.Fonts.labelLarge) + .padding(6) + } + .foregroundColor(Theme.Colors.white) + .background( + Circle() + .foregroundColor(Theme.Colors.accentButtonColor) + ) + }) + } } .padding(.horizontal, 24) @@ -176,29 +177,31 @@ public struct PostsView: View { .multilineTextAlignment(.center) .frame(maxWidth: .infinity) .padding(.top, 40) - Text(DiscussionLocalization.Posts.NoDiscussion.description) - .font(Theme.Fonts.bodyLarge) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .padding(.top, 12) - StyledButton( - DiscussionLocalization.Posts.NoDiscussion.addPost, - action: { - router.createNewThread(courseID: courseID, - selectedTopic: currentBlockID, - onPostCreated: { - reloadPage(onSuccess: { - withAnimation { - scroll.scrollTo(1) - } + if !(viewModel.isBlackedOut ?? false) { + Text(DiscussionLocalization.Posts.NoDiscussion.description) + .font(Theme.Fonts.bodyLarge) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 12) + StyledButton( + DiscussionLocalization.Posts.NoDiscussion.addPost, + action: { + router.createNewThread(courseID: courseID, + selectedTopic: currentBlockID, + onPostCreated: { + reloadPage(onSuccess: { + withAnimation { + scroll.scrollTo(1) + } + }) }) - }) - }, - isTransparent: true) - .frame(width: 215) - .padding(.top, 40) - .colorMultiply(Theme.Colors.accentColor) - .disabled(viewModel.isBlackedOut ?? false) + }, + isTransparent: true) + .frame(width: 215) + .padding(.top, 40) + .colorMultiply(Theme.Colors.accentColor) + .disabled(viewModel.isBlackedOut ?? false) + } } .padding(24) .padding(.top, 100) From 7a6637859c3a53672603ddbd5266b414447c9ed7 Mon Sep 17 00:00:00 2001 From: forgotvas Date: Fri, 29 Mar 2024 18:28:00 +0300 Subject: [PATCH 111/136] fix: Video starts playing on neighboring cards... without video #362 --- Course/Course/Presentation/Unit/CourseUnitView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index 1219cea91..b6be80a9f 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -171,7 +171,7 @@ public struct CourseUnitView: View { switch LessonType.from(block, streamingQuality: viewModel.streamingQuality) { // MARK: YouTube case let .youtube(url, blockID): - if index >= viewModel.index - 1 && index <= viewModel.index + 1 { + if index == viewModel.index { if viewModel.connectivity.isInternetAvaliable { YouTubeView( name: block.displayName, From 575eabcdae34e7938c833bddf830692df83911bc Mon Sep 17 00:00:00 2001 From: forgotvas Date: Fri, 29 Mar 2024 18:47:38 +0300 Subject: [PATCH 112/136] fix: do not play video when jump to new vertical --- Course/Course/Presentation/Unit/CourseUnitView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index b6be80a9f..67d89ffaa 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -121,6 +121,7 @@ public struct CourseUnitView: View { ) { [weak viewModel] vertical in let data = viewModel?.dataFor(blockId: vertical.childs.first?.id) viewModel?.route(to: data) + playerStateSubject.send(VideoPlayerState.kill) } } } From 433e8458538d16d6ab5cc0dffb4a3081fff22dd2 Mon Sep 17 00:00:00 2001 From: forgotvas Date: Fri, 29 Mar 2024 19:27:29 +0300 Subject: [PATCH 113/136] chore: pause pip when player begins to play --- .../Video/EncodedVideoPlayer.swift | 5 ++- .../Video/EncodedVideoPlayerViewModel.swift | 4 +- .../Video/PlayerViewController.swift | 10 +++-- .../Video/PlayerViewControllerHolder.swift | 40 +++++++++++++++---- OpenEdX/Managers/PipManager.swift | 9 +++++ 5 files changed, 54 insertions(+), 14 deletions(-) diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift index c3f64dc17..ac6411299 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift @@ -81,7 +81,10 @@ public struct EncodedVideoPlayer: View { .frame(minWidth: isHorizontal ? reader.size.width * 0.6 : 380) .cornerRadius(12) .onAppear { - viewModel.controller.player?.play() + if !viewModel.controllerHolder.isPlayingInPip, + !viewModel.controllerHolder.isOtherPlayerInPip { + viewModel.controller.player?.play() + } } if isHorizontal { Spacer() diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift index 2c7689a16..8a3a48a84 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift @@ -63,11 +63,11 @@ public class EncodedVideoPlayerViewModel: VideoPlayerViewModel { playerStateSubject.sink(receiveValue: { [weak self] state in switch state { case .pause: - if self?.controllerHolder.isPipModeActive != true { + if self?.controllerHolder.isPlayingInPip != true { self?.controller.player?.pause() } case .kill: - if self?.controllerHolder.isPipModeActive != true { + if self?.controllerHolder.isPlayingInPip != true { self?.controller.player?.replaceCurrentItem(with: nil) } case .none: diff --git a/Course/Course/Presentation/Video/PlayerViewController.swift b/Course/Course/Presentation/Video/PlayerViewController.swift index 4671663fb..ed22537c8 100644 --- a/Course/Course/Presentation/Video/PlayerViewController.swift +++ b/Course/Course/Presentation/Video/PlayerViewController.swift @@ -33,7 +33,8 @@ struct PlayerViewController: UIViewControllerRepresentable { } func makeUIViewController(context: Context) -> AVPlayerViewController { - if playerHolder.isPipModeActive { + context.coordinator.currentHolder = playerHolder + if playerHolder.isPlayingInPip { return playerHolder.playerController } @@ -59,7 +60,7 @@ struct PlayerViewController: UIViewControllerRepresentable { func updateUIViewController(_ playerController: AVPlayerViewController, context: Context) { let asset = playerController.player?.currentItem?.asset as? AVURLAsset - if asset?.url.absoluteString != videoURL?.absoluteString && !playerHolder.isPipModeActive { + if asset?.url.absoluteString != videoURL?.absoluteString && !playerHolder.isPlayingInPip { let player = context.coordinator.player(from: playerController) player?.replaceCurrentItem(with: AVPlayerItem(url: videoURL!)) player?.currentItem?.preferredMaximumResolution = videoResolution @@ -83,6 +84,7 @@ struct PlayerViewController: UIViewControllerRepresentable { var currentPlayer: AVPlayer? var observer: Any? var cancellations: [AnyCancellable] = [] + weak var currentHolder: PlayerViewControllerHolder? func player(from playerController: AVPlayerViewController) -> AVPlayer? { var player = playerController.player @@ -117,9 +119,9 @@ struct PlayerViewController: UIViewControllerRepresentable { } player.publisher(for: \.rate) - .sink { rate in + .sink {[weak self] rate in guard rate > 0 else { return } - + self?.currentHolder?.pausePipIfNeed() } .store(in: &cancellations) diff --git a/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift b/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift index 2a1cde020..badd5387c 100644 --- a/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift +++ b/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift @@ -10,14 +10,21 @@ import Combine import Swinject public protocol PipManagerProtocol { + var isPipActive: Bool { get } + func holder(for url: URL?, blockID: String, courseID: String, selectedCourseTab: Int) -> PlayerViewControllerHolder? func set(holder: PlayerViewControllerHolder) func remove(holder: PlayerViewControllerHolder) func restore(holder: PlayerViewControllerHolder) async throws + func pauseCurrentPipVideo() } #if DEBUG public class PipManagerProtocolMock: PipManagerProtocol { + public var isPipActive: Bool { + false + } + public init() {} public func holder( for url: URL?, @@ -30,6 +37,7 @@ public class PipManagerProtocolMock: PipManagerProtocol { public func set(holder: PlayerViewControllerHolder) {} public func remove(holder: PlayerViewControllerHolder) {} public func restore(holder: PlayerViewControllerHolder) async throws {} + public func pauseCurrentPipVideo() {} } #endif @@ -38,7 +46,18 @@ public class PlayerViewControllerHolder: NSObject, AVPlayerViewControllerDelegat public let blockID: String public let courseID: String public let selectedCourseTab: Int - public var isPipModeActive: Bool = false + public var isPlayingInPip: Bool = false + public var isOtherPlayerInPip: Bool { + let holder = pipManager.holder( + for: url, + blockID: blockID, + courseID: courseID, + selectedCourseTab: selectedCourseTab + ) + return holder == nil && pipManager.isPipActive + } + + private let pipManager: PipManagerProtocol public lazy var playerController: AVPlayerViewController = { let playerController = AVPlayerViewController() @@ -56,24 +75,25 @@ public class PlayerViewControllerHolder: NSObject, AVPlayerViewControllerDelegat self.blockID = blockID self.courseID = courseID self.selectedCourseTab = selectedCourseTab + self.pipManager = Container.shared.resolve(PipManagerProtocol.self)! } public func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) { - isPipModeActive = true - Container.shared.resolve(PipManagerProtocol.self)?.set(holder: self) + isPlayingInPip = true + pipManager.set(holder: self) } public func playerViewController( _ playerViewController: AVPlayerViewController, failedToStartPictureInPictureWithError error: any Error ) { - isPipModeActive = false - Container.shared.resolve(PipManagerProtocol.self)?.remove(holder: self) + isPlayingInPip = false + pipManager.remove(holder: self) } public func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) { - isPipModeActive = false - Container.shared.resolve(PipManagerProtocol.self)?.remove(holder: self) + isPlayingInPip = false + pipManager.remove(holder: self) } public func playerViewControllerRestoreUserInterfaceForPictureInPictureStop( @@ -96,4 +116,10 @@ public class PlayerViewControllerHolder: NSObject, AVPlayerViewControllerDelegat blockID == object.blockID && selectedCourseTab == object.selectedCourseTab } + + public func pausePipIfNeed() { + if !isPlayingInPip { + pipManager.pauseCurrentPipVideo() + } + } } diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift index 1c68b1ed6..21cde8a19 100644 --- a/OpenEdX/Managers/PipManager.swift +++ b/OpenEdX/Managers/PipManager.swift @@ -15,6 +15,10 @@ public class PipManager: PipManagerProtocol { let courseInteractor: CourseInteractorProtocol let router: Router let isNestedListEnabled: Bool + public var isPipActive: Bool { + controllerHolder != nil + } + public init( router: Router, discoveryInteractor: DiscoveryInteractorProtocol, @@ -70,6 +74,11 @@ public class PipManager: PipManagerProtocol { try await navigate(to: holder) } + public func pauseCurrentPipVideo() { + guard let holder = controllerHolder else { return } + holder.playerController.player?.pause() + } + @MainActor private func navigate(to holder: PlayerViewControllerHolder) async throws { let currentControllers = router.getNavigationController().viewControllers From 8f9756f5c6c228402e2cbd81181759ae38aa902b Mon Sep 17 00:00:00 2001 From: forgotvas Date: Fri, 29 Mar 2024 19:41:05 +0300 Subject: [PATCH 114/136] chore: pause pip when youtube starts --- .../Presentation/Video/PlayerViewControllerHolder.swift | 2 +- .../Course/Presentation/Video/YouTubeVideoPlayer.swift | 9 ++++++--- .../Presentation/Video/YouTubeVideoPlayerViewModel.swift | 9 ++++++--- OpenEdX/DI/ScreenAssembly.swift | 3 ++- OpenEdX/Managers/PipManager.swift | 1 + 5 files changed, 16 insertions(+), 8 deletions(-) diff --git a/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift b/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift index badd5387c..a1f21aa79 100644 --- a/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift +++ b/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift @@ -53,7 +53,7 @@ public class PlayerViewControllerHolder: NSObject, AVPlayerViewControllerDelegat blockID: blockID, courseID: courseID, selectedCourseTab: selectedCourseTab - ) + ) return holder == nil && pipManager.isPipActive } diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift index 08a868665..82955ee63 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift @@ -88,10 +88,13 @@ struct YouTubeVideoPlayer_Previews: PreviewProvider { languages: [], playerStateSubject: CurrentValueSubject(nil), interactor: CourseInteractor(repository: CourseRepositoryMock()), - router: CourseRouterMock(), + router: CourseRouterMock(), appStorage: CoreStorageMock(), - connectivity: Connectivity()), - isOnScreen: true) + connectivity: Connectivity(), + pipManager: PipManagerProtocolMock() + ), + isOnScreen: true + ) } } #endif diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift index e3486d600..02524b33b 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift @@ -23,6 +23,7 @@ public class YouTubeVideoPlayerViewModel: VideoPlayerViewModel { private var duration: Double? private var isViewedOnce: Bool = false private var url: String + private let pipManager: PipManagerProtocol public init( url: String, @@ -33,13 +34,14 @@ public class YouTubeVideoPlayerViewModel: VideoPlayerViewModel { interactor: CourseInteractorProtocol, router: CourseRouter, appStorage: CoreStorage, - connectivity: ConnectivityProtocol + connectivity: ConnectivityProtocol, + pipManager: PipManagerProtocol ) { self.url = url let videoID = url.replacingOccurrences(of: "https://www.youtube.com/watch?v=", with: "") let configuration = YouTubePlayer.Configuration(configure: { - $0.autoPlay = true + $0.autoPlay = !pipManager.isPipActive $0.playInline = true $0.showFullscreenButton = true $0.allowsPictureInPictureMediaPlayback = false @@ -55,7 +57,7 @@ public class YouTubeVideoPlayerViewModel: VideoPlayerViewModel { """ }) self.youtubePlayer = YouTubePlayer(source: .video(id: videoID), configuration: configuration) - + self.pipManager = pipManager super.init( blockID: blockID, courseID: courseID, @@ -123,6 +125,7 @@ public class YouTubeVideoPlayerViewModel: VideoPlayerViewModel { self.play = false case .playing: self.play = true + self.pipManager.pauseCurrentPipVideo() case .paused: self.play = false case .buffering, .cued: diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index db8829006..4ca9a3867 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -320,7 +320,8 @@ class ScreenAssembly: Assembly { interactor: r.resolve(CourseInteractorProtocol.self)!, router: r.resolve(CourseRouter.self)!, appStorage: r.resolve(CoreStorage.self)!, - connectivity: r.resolve(ConnectivityProtocol.self)! + connectivity: r.resolve(ConnectivityProtocol.self)!, + pipManager: r.resolve(PipManagerProtocol.self)! ) } diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift index 21cde8a19..c2446f474 100644 --- a/OpenEdX/Managers/PipManager.swift +++ b/OpenEdX/Managers/PipManager.swift @@ -48,6 +48,7 @@ public class PipManager: PipManagerProtocol { public func set(holder: PlayerViewControllerHolder) { controllerHolder = holder + print("ALARM \(holder.playerController.player)") } public func remove(holder: PlayerViewControllerHolder) { From b0d6edea92d9a3ee19007cfee05f50b739e1e089 Mon Sep 17 00:00:00 2001 From: forgotvas Date: Fri, 29 Mar 2024 20:01:51 +0300 Subject: [PATCH 115/136] chore: pause players when pip plays --- .../Video/PlayerViewController.swift | 10 +++++++++- .../Video/PlayerViewControllerHolder.swift | 18 ++++++++++++------ .../Video/YouTubeVideoPlayerViewModel.swift | 7 +++++++ OpenEdX/Managers/PipManager.swift | 19 ++++++++++++++++++- 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/Course/Course/Presentation/Video/PlayerViewController.swift b/Course/Course/Presentation/Video/PlayerViewController.swift index ed22537c8..5ac17bcc5 100644 --- a/Course/Course/Presentation/Video/PlayerViewController.swift +++ b/Course/Course/Presentation/Video/PlayerViewController.swift @@ -124,7 +124,15 @@ struct PlayerViewController: UIViewControllerRepresentable { self?.currentHolder?.pausePipIfNeed() } .store(in: &cancellations) - + currentHolder?.pipRatePublisher()? + .sink {[weak self] rate in + guard rate > 0 else { return } + if self?.currentHolder?.isPlayingInPip == false { + self?.currentPlayer?.pause() + } + } + .store(in: &cancellations) + currentPlayer = player } diff --git a/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift b/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift index a1f21aa79..3ba64b192 100644 --- a/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift +++ b/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift @@ -16,6 +16,7 @@ public protocol PipManagerProtocol { func set(holder: PlayerViewControllerHolder) func remove(holder: PlayerViewControllerHolder) func restore(holder: PlayerViewControllerHolder) async throws + func pipRatePublisher() -> AnyPublisher? func pauseCurrentPipVideo() } @@ -37,6 +38,7 @@ public class PipManagerProtocolMock: PipManagerProtocol { public func set(holder: PlayerViewControllerHolder) {} public func remove(holder: PlayerViewControllerHolder) {} public func restore(holder: PlayerViewControllerHolder) async throws {} + public func pipRatePublisher() -> AnyPublisher? { nil } public func pauseCurrentPipVideo() {} } #endif @@ -56,15 +58,15 @@ public class PlayerViewControllerHolder: NSObject, AVPlayerViewControllerDelegat ) return holder == nil && pipManager.isPipActive } - + private let pipManager: PipManagerProtocol - + public lazy var playerController: AVPlayerViewController = { let playerController = AVPlayerViewController() playerController.delegate = self return playerController }() - + public init( url: URL?, blockID: String, @@ -77,12 +79,12 @@ public class PlayerViewControllerHolder: NSObject, AVPlayerViewControllerDelegat self.selectedCourseTab = selectedCourseTab self.pipManager = Container.shared.resolve(PipManagerProtocol.self)! } - + public func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) { isPlayingInPip = true pipManager.set(holder: self) } - + public func playerViewController( _ playerViewController: AVPlayerViewController, failedToStartPictureInPictureWithError error: any Error @@ -95,7 +97,7 @@ public class PlayerViewControllerHolder: NSObject, AVPlayerViewControllerDelegat isPlayingInPip = false pipManager.remove(holder: self) } - + public func playerViewControllerRestoreUserInterfaceForPictureInPictureStop( _ playerViewController: AVPlayerViewController ) async -> Bool { @@ -122,4 +124,8 @@ public class PlayerViewControllerHolder: NSObject, AVPlayerViewControllerDelegat pipManager.pauseCurrentPipVideo() } } + + public func pipRatePublisher() -> AnyPublisher? { + pipManager.pipRatePublisher() + } } diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift index 02524b33b..92bdad530 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift @@ -139,5 +139,12 @@ public class YouTubeVideoPlayerViewModel: VideoPlayerViewModel { self.isLoading = false } }).store(in: &subscription) + + pipManager.pipRatePublisher()? + .sink {[weak self] rate in + guard rate > 0 else { return } + self?.youtubePlayer.pause() + } + .store(in: &subscription) } } diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift index c2446f474..63a6d4498 100644 --- a/OpenEdX/Managers/PipManager.swift +++ b/OpenEdX/Managers/PipManager.swift @@ -6,6 +6,7 @@ // import Course +import Combine import Discovery import SwiftUI @@ -19,6 +20,9 @@ public class PipManager: PipManagerProtocol { controllerHolder != nil } + private var ratePublisher: PassthroughSubject? + private var cancellations: [AnyCancellable] = [] + public init( router: Router, discoveryInteractor: DiscoveryInteractorProtocol, @@ -48,15 +52,28 @@ public class PipManager: PipManagerProtocol { public func set(holder: PlayerViewControllerHolder) { controllerHolder = holder - print("ALARM \(holder.playerController.player)") + ratePublisher = PassthroughSubject() + cancellations.removeAll() + holder.playerController.player?.publisher(for: \.rate) + .sink { [weak self] rate in + self?.ratePublisher?.send(rate) + } + .store(in: &cancellations) } public func remove(holder: PlayerViewControllerHolder) { if controllerHolder == holder { controllerHolder = nil + cancellations.removeAll() + ratePublisher = nil } } + public func pipRatePublisher() -> AnyPublisher? { + ratePublisher? + .eraseToAnyPublisher() + } + @MainActor public func restore(holder: PlayerViewControllerHolder) async throws { let courseID = holder.courseID From c5478435556e0b591c7111f936b49e651878bd46 Mon Sep 17 00:00:00 2001 From: forgotvas Date: Fri, 29 Mar 2024 20:11:59 +0300 Subject: [PATCH 116/136] chore: merge conflict --- OpenEdX/Router.swift | 122 +++++++++++++++++++++++++++++-------------- 1 file changed, 84 insertions(+), 38 deletions(-) diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index c517a3cc6..9f8f0fbd6 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -320,6 +320,25 @@ public class Router: AuthorizationRouter, chapterIndex: Int, sequentialIndex: Int ) { + let controller = getVerticalController( + courseID: courseID, + courseName: courseName, + title: title, + chapters: chapters, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex + ) + navigationController.pushViewController(controller, animated: true) + } + + public func getVerticalController( + courseID: String, + courseName: String, + title: String, + chapters: [CourseChapter], + chapterIndex: Int, + sequentialIndex: Int + ) -> UIHostingController { let viewModel = Container.shared.resolve( CourseVerticalViewModel.self, arguments: chapters, @@ -333,8 +352,7 @@ public class Router: AuthorizationRouter, courseID: courseID, viewModel: viewModel ) - let controller = UIHostingController(rootView: view) - navigationController.pushViewController(controller, animated: true) + return UIHostingController(rootView: view) } public func showCourseScreens( @@ -346,6 +364,27 @@ public class Router: AuthorizationRouter, enrollmentEnd: Date?, title: String ) { + let controller = getCourseScreensController( + courseID: courseID, + isActive: isActive, + courseStart: courseStart, + courseEnd: courseEnd, + enrollmentStart: enrollmentStart, + enrollmentEnd: enrollmentEnd, + title: title + ) + navigationController.pushViewController(controller, animated: true) + } + + public func getCourseScreensController( + courseID: String, + isActive: Bool?, + courseStart: Date?, + courseEnd: Date?, + enrollmentStart: Date?, + enrollmentEnd: Date?, + title: String + ) -> UIHostingController { let vm = Container.shared.resolve( CourseContainerViewModel.self, arguments: isActive, @@ -360,8 +399,7 @@ public class Router: AuthorizationRouter, title: title ) - let controller = UIHostingController(rootView: screensView) - navigationController.pushViewController(controller, animated: true) + return UIHostingController(rootView: screensView) } public func showHandoutsUpdatesView( @@ -384,12 +422,32 @@ public class Router: AuthorizationRouter, courseName: String, blockId: String, courseID: String, - sectionName: String, verticalIndex: Int, chapters: [CourseChapter], chapterIndex: Int, sequentialIndex: Int ) { + let controller = getUnitController( + courseName: courseName, + blockId: blockId, + courseID: courseID, + verticalIndex: verticalIndex, + chapters: chapters, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex + ) + navigationController.pushViewController(controller, animated: true) + } + + public func getUnitController( + courseName: String, + blockId: String, + courseID: String, + verticalIndex: Int, + chapters: [CourseChapter], + chapterIndex: Int, + sequentialIndex: Int + ) -> UIHostingController { let viewModel = Container.shared.resolve( CourseUnitViewModel.self, arguments: blockId, @@ -404,9 +462,8 @@ public class Router: AuthorizationRouter, let config = Container.shared.resolve(ConfigProtocol.self) let isDropdownActive = config?.uiComponents.courseNestedListEnabled ?? false - let view = CourseUnitView(viewModel: viewModel, sectionName: sectionName, isDropdownActive: isDropdownActive) - let controller = UIHostingController(rootView: view) - navigationController.pushViewController(controller, animated: true) + let view = CourseUnitView(viewModel: viewModel, isDropdownActive: isDropdownActive) + return UIHostingController(rootView: view) } public func showCourseComponent( @@ -488,52 +545,41 @@ public class Router: AuthorizationRouter, courseName: String, blockId: String, courseID: String, - sectionName: String, verticalIndex: Int, chapters: [CourseChapter], chapterIndex: Int, sequentialIndex: Int, animated: Bool ) { - - let vmVertical = Container.shared.resolve( - CourseVerticalViewModel.self, - arguments: chapters, - chapterIndex, - sequentialIndex - )! - - let viewVertical = CourseVerticalView( - title: chapters[chapterIndex].childs[sequentialIndex].displayName, + + let controllerUnit = getUnitController( courseName: courseName, + blockId: blockId, courseID: courseID, - viewModel: vmVertical + verticalIndex: verticalIndex, + chapters: chapters, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex ) - let controllerVertical = UIHostingController(rootView: viewVertical) - - let viewModel = Container.shared.resolve( - CourseUnitViewModel.self, - arguments: blockId, - courseID, - courseName, - chapters, - chapterIndex, - sequentialIndex, - verticalIndex - )! let config = Container.shared.resolve(ConfigProtocol.self) - let isDropdownActive = config?.uiComponents.courseNestedListEnabled ?? false - - let view = CourseUnitView(viewModel: viewModel, sectionName: sectionName, isDropdownActive: isDropdownActive) - let controllerUnit = UIHostingController(rootView: view) + let isCourseNestedListEnabled = config?.uiComponents.courseNestedListEnabled ?? false + var controllers = navigationController.viewControllers - if let config = container.resolve(ConfigProtocol.self), - config.uiComponents.courseNestedListEnabled { + if isCourseNestedListEnabled || currentCourseTabSelection == CourseTab.dates.rawValue { controllers.removeLast(1) controllers.append(contentsOf: [controllerUnit]) } else { + let controllerVertical = getVerticalController( + courseID: courseID, + courseName: courseName, + title: chapters[chapterIndex].childs[sequentialIndex].displayName, + chapters: chapters, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex + ) + controllers.removeLast(2) controllers.append(contentsOf: [controllerVertical, controllerUnit]) } From c68550dc196aa54a9a75f703de2b35064985ec0c Mon Sep 17 00:00:00 2001 From: forgotvas Date: Fri, 29 Mar 2024 20:14:23 +0300 Subject: [PATCH 117/136] chore: merge conflict --- OpenEdX/Router.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 9f8f0fbd6..4ed940a18 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -500,7 +500,6 @@ public class Router: AuthorizationRouter, courseName: courseStructure.displayName, blockId: block.blockId, courseID: courseStructure.id, - sectionName: courseName ?? "", verticalIndex: verticalPosition ?? 0, chapters: courseStructure.childs, chapterIndex: chapterPosition ?? 0, From 8a6da8af5bd39be8885c2af4bfbe872cd366b046 Mon Sep 17 00:00:00 2001 From: forgotvas Date: Fri, 29 Mar 2024 23:27:52 +0300 Subject: [PATCH 118/136] chore: removed useless code --- .../Course/Presentation/Video/EncodedVideoPlayerViewModel.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift index 8a3a48a84..bb5eb8d3e 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift @@ -8,7 +8,6 @@ import _AVKit_SwiftUI import Core import Combine -import Swinject public class EncodedVideoPlayerViewModel: VideoPlayerViewModel { From 14be4c35ce2f4298eaa2d6920bf6f079913172bb Mon Sep 17 00:00:00 2001 From: forgotvas Date: Sat, 30 Mar 2024 00:19:01 +0300 Subject: [PATCH 119/136] chore: little refactor --- .../Presentation/Unit/CourseUnitView.swift | 2 +- .../Unit/CourseUnitViewModel.swift | 63 ++++++++++--------- .../Video/PlayerViewController.swift | 9 +-- OpenEdX/DI/AppAssembly.swift | 4 -- OpenEdX/Managers/PipManager.swift | 55 +++++++--------- 5 files changed, 65 insertions(+), 68 deletions(-) diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index 2b00da51b..dff0a0ea9 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -119,7 +119,7 @@ public struct CourseUnitView: View { offsetY: isHorizontal ? landscapeTopSpacing : portraitTopSpacing, showDropdown: $showDropdown ) { [weak viewModel] vertical in - let data = viewModel?.dataFor(blockId: vertical.childs.first?.id) + let data = VerticalData.dataFor(blockId: vertical.childs.first?.id, in: viewModel?.chapters ?? []) viewModel?.route(to: data) playerStateSubject.send(VideoPlayerState.kill) } diff --git a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift index 221c8942e..8f4be45b8 100644 --- a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift +++ b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift @@ -54,6 +54,39 @@ public enum LessonType: Equatable { } } +public struct VerticalData: Equatable { + public var chapterIndex: Int + public var sequentialIndex: Int + public var verticalIndex: Int + public var blockIndex: Int + + public init(chapterIndex: Int, sequentialIndex: Int, verticalIndex: Int, blockIndex: Int) { + self.chapterIndex = chapterIndex + self.sequentialIndex = sequentialIndex + self.verticalIndex = verticalIndex + self.blockIndex = blockIndex + } + + public static func dataFor(blockId: String?, in chapters: [CourseChapter]) -> VerticalData? { + guard let blockId = blockId else { return nil } + for (chapterIndex, chapter) in chapters.enumerated() { + for (sequentialIndex, sequential) in chapter.childs.enumerated() { + for (verticalIndex, vertical) in sequential.childs.enumerated() { + for (blockIndex, block) in vertical.childs.enumerated() where block.id.contains(blockId) { + return VerticalData( + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex, + verticalIndex: verticalIndex, + blockIndex: blockIndex + ) + } + } + } + } + return nil + } +} + public class CourseUnitViewModel: ObservableObject { enum LessonAction { @@ -61,13 +94,6 @@ public class CourseUnitViewModel: ObservableObject { case previous } - struct VerticalData: Equatable { - var chapterIndex: Int - var sequentialIndex: Int - var verticalIndex: Int - var blockIndex: Int - } - var verticals: [CourseVertical] var verticalIndex: Int var courseName: String @@ -310,7 +336,7 @@ public class CourseUnitViewModel: ObservableObject { let block = blockFor(index: data.blockIndex, in: vertical) { router.replaceCourseUnit( courseName: courseName, - blockId: block.id, + blockId: block.blockId, courseID: courseID, verticalIndex: data.verticalIndex, chapters: chapters, @@ -322,29 +348,10 @@ public class CourseUnitViewModel: ObservableObject { } public func route(to blockId: String?) { - guard let data = dataFor(blockId: blockId) else { return } + guard let data = VerticalData.dataFor(blockId: blockId, in: chapters) else { return } route(to: data, animated: true) } - func dataFor(blockId: String?) -> VerticalData? { - guard let blockId = blockId else { return nil } - for (chapterIndex, chapter) in chapters.enumerated() { - for (sequentialIndex, sequential) in chapter.childs.enumerated() { - for (verticalIndex, vertical) in sequential.childs.enumerated() { - for (blockIndex, block) in vertical.childs.enumerated() where block.id.contains(blockId) { - return VerticalData( - chapterIndex: chapterIndex, - sequentialIndex: sequentialIndex, - verticalIndex: verticalIndex, - blockIndex: blockIndex - ) - } - } - } - } - return nil - } - public var currentCourseId: String { courseID } diff --git a/Course/Course/Presentation/Video/PlayerViewController.swift b/Course/Course/Presentation/Video/PlayerViewController.swift index 5ac17bcc5..0bb477635 100644 --- a/Course/Course/Presentation/Video/PlayerViewController.swift +++ b/Course/Course/Presentation/Video/PlayerViewController.swift @@ -97,11 +97,12 @@ struct PlayerViewController: UIViewControllerRepresentable { } func setPlayer(_ player: AVPlayer?, currentProgress: @escaping ((Float, Double) -> Void)) { - guard let player = player else { return } cancellations.removeAll() if let observer = observer { currentPlayer?.removeTimeObserver(observer) - currentPlayer?.pause() + if currentHolder?.isPlayingInPip == false { + currentPlayer?.pause() + } } let interval = CMTime( @@ -109,7 +110,7 @@ struct PlayerViewController: UIViewControllerRepresentable { preferredTimescale: CMTimeScale(NSEC_PER_SEC) ) - observer = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) {[weak player] time in + observer = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) {[weak player] time in var progress: Float = .zero let currentSeconds = CMTimeGetSeconds(time) guard let duration = player?.currentItem?.duration else { return } @@ -118,7 +119,7 @@ struct PlayerViewController: UIViewControllerRepresentable { currentProgress(progress, currentSeconds) } - player.publisher(for: \.rate) + player?.publisher(for: \.rate) .sink {[weak self] rate in guard rate > 0 else { return } self?.currentHolder?.pausePipIfNeed() diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 4998e62f1..12ee2d514 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -127,10 +127,6 @@ class AppAssembly: Assembly { r.resolve(Router.self)! }.inObjectScope(.container) - container.register(DeepLinkRouter.self) { r in - r.resolve(Router.self)! - }.inObjectScope(.container) - container.register(ConfigProtocol.self) { _ in Config() }.inObjectScope(.container) diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift index 63a6d4498..8720ae03f 100644 --- a/OpenEdX/Managers/PipManager.swift +++ b/OpenEdX/Managers/PipManager.swift @@ -133,21 +133,16 @@ public class PipManager: PipManagerProtocol { if holder.selectedCourseTab == CourseTab.videos.rawValue { courseStructure = courseInteractor.getCourseVideoBlocks(fullStructure: courseStructure) } - for (chapterIndex, chapter) in courseStructure.childs.enumerated() { - for (sequentialIndex, sequential) in chapter.childs.enumerated() { - for (verticalIndex, vertical) in sequential.childs.enumerated() { - for block in vertical.childs where block.id == holder.blockID { - return router.getVerticalController( - courseID: holder.courseID, - courseName: courseStructure.displayName, - title: courseStructure.childs[chapterIndex].childs[sequentialIndex].displayName, - chapters: courseStructure.childs, - chapterIndex: chapterIndex, - sequentialIndex: sequentialIndex - ) - } - } - } + + if let data = VerticalData.dataFor(blockId: holder.blockID, in: courseStructure.childs) { + return router.getVerticalController( + courseID: holder.courseID, + courseName: courseStructure.displayName, + title: courseStructure.childs[data.chapterIndex].childs[data.sequentialIndex].displayName, + chapters: courseStructure.childs, + chapterIndex: data.chapterIndex, + sequentialIndex: data.sequentialIndex + ) } throw PipManagerError.cantCreateCourseVerticalView @@ -162,22 +157,20 @@ public class PipManager: PipManagerProtocol { if holder.selectedCourseTab == CourseTab.videos.rawValue { courseStructure = courseInteractor.getCourseVideoBlocks(fullStructure: courseStructure) } - for (chapterIndex, chapter) in courseStructure.childs.enumerated() { - for (sequentialIndex, sequential) in chapter.childs.enumerated() { - for (verticalIndex, vertical) in sequential.childs.enumerated() { - for block in vertical.childs where block.id == holder.blockID { - return router.getUnitController( - courseName: courseStructure.displayName, - blockId: block.blockId, - courseID: courseStructure.id, - verticalIndex: verticalIndex, - chapters: courseStructure.childs, - chapterIndex: chapterIndex, - sequentialIndex: sequentialIndex - ) - } - } - } + if let data = VerticalData.dataFor(blockId: holder.blockID, in: courseStructure.childs) { + let chapter = courseStructure.childs[data.chapterIndex] + let sequential = chapter.childs[data.sequentialIndex] + let vertical = sequential.childs[data.verticalIndex] + let block = vertical.childs[data.blockIndex] + return router.getUnitController( + courseName: courseStructure.displayName, + blockId: block.id, + courseID: courseStructure.id, + verticalIndex: data.verticalIndex, + chapters: courseStructure.childs, + chapterIndex: data.chapterIndex, + sequentialIndex: data.sequentialIndex + ) } throw PipManagerError.cantCreateCourseUnitView From 7b78ddae413dc5f8d11266a908a8932a022d6bef Mon Sep 17 00:00:00 2001 From: Eugene Yatsenko Date: Mon, 1 Apr 2024 10:24:40 +0200 Subject: [PATCH 120/136] chore: resolve PR comments --- Discussion/Discussion/Domain/Model/DiscussionInfo.swift | 4 ++-- .../Presentation/Comments/Responses/ResponsesView.swift | 4 ++-- .../Presentation/Comments/Responses/ResponsesViewModel.swift | 1 - 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Discussion/Discussion/Domain/Model/DiscussionInfo.swift b/Discussion/Discussion/Domain/Model/DiscussionInfo.swift index f471676b6..c04b936c4 100644 --- a/Discussion/Discussion/Domain/Model/DiscussionInfo.swift +++ b/Discussion/Discussion/Domain/Model/DiscussionInfo.swift @@ -17,8 +17,8 @@ public struct DiscussionInfo { } var isBlackedOut = false for blackout in blackouts { - let start = Date(iso8601: blackout.start) - let end = Date(iso8601: blackout.end) + let start = Date(iso8601: blackout.start) + let end = Date(iso8601: blackout.end) if Date().isEarlierThanOrEqualTo(date: end) && Date().isLaterThanOrEqualTo(date: start) { diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index 11f559a09..80c9cb71f 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -253,7 +253,7 @@ struct ResponsesView_Previews: PreviewProvider { commentID: "", viewModel: viewModel, router: router, - parentComment: post, + parentComment: post, isBlackedOut: false ) .loadFonts() @@ -264,7 +264,7 @@ struct ResponsesView_Previews: PreviewProvider { commentID: "", viewModel: viewModel, router: router, - parentComment: post, + parentComment: post, isBlackedOut: false ) .loadFonts() diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift index aa74efb61..91ac5d515 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift @@ -14,7 +14,6 @@ public class ResponsesViewModel: BaseResponsesViewModel, ObservableObject { @Published var scrollTrigger: Bool = false private let threadStateSubject: CurrentValueSubject - public var isBlackedOut: Bool = false public init( From 0f00f7350f98a6089ae00b28ad4d7807378105fd Mon Sep 17 00:00:00 2001 From: Shafqat Muneer Date: Wed, 3 Apr 2024 16:30:44 +0500 Subject: [PATCH 121/136] feat: Calendar deep link to course component --- .../DeepLinkManager/DeepLinkManager.swift | 34 +++++++++++++++++-- .../DeepLinkRouter/DeepLinkRouter.swift | 15 ++++++-- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift b/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift index 33d3ba70f..be43a71c6 100644 --- a/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift +++ b/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift @@ -165,6 +165,10 @@ public class DeepLinkManager { type == .courseHandout || type == .courseAnnouncement } + + private func isCourseComponent(type: DeepLinkType) -> Bool { + type == .courseComponent + } @MainActor private func navigateToScreen( @@ -191,7 +195,8 @@ public class DeepLinkManager { .courseAnnouncement, .discussionTopic, .discussionPost, - .discussionComment: + .discussionComment, + .courseComponent: await showCourseScreen(with: type, link: link) case .program, .programDetail: guard config.program.enabled else { return } @@ -277,6 +282,15 @@ public class DeepLinkManager { } return } + + if self.isCourseComponent(type: type) { + self.router.showProgress() + Task { + await self.showCourseComponent(link: link, courseDetails: courseDetails) + self.router.dismissProgress() + } + return + } } } catch { router.dismissProgress() @@ -394,7 +408,23 @@ public class DeepLinkManager { break } } - + + @MainActor + private func showCourseComponent( + link: DeepLink, + courseDetails: CourseDetails + ) async { + guard let courseID = link.courseID else { return } + guard let courseStructure = try? await courseInteractor.getCourseBlocks(courseID: courseID) else { + return + } + router.showCourseComponent( + componentID: link.componentID ?? "", + courseStructure: courseStructure, + blockLink: "" + ) + } + @MainActor private func showEditProfile() async { guard let userProfile = try? await profileInteractor.getMyProfile() else { diff --git a/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift b/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift index b0ce73135..1ff619e33 100644 --- a/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift +++ b/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift @@ -25,6 +25,11 @@ public protocol DeepLinkRouter: BaseRouter { courseDetails: CourseDetails, completion: @escaping () -> Void ) + func showCourseComponent( + componentID: String, + courseStructure: CourseStructure, + blockLink: String + ) func showAnnouncement( courseDetails: CourseDetails, updates: [CourseUpdate] @@ -124,7 +129,8 @@ extension Router: DeepLinkRouter { .discussions, .courseHandout, .courseAnnouncement, - .courseDashboard: + .courseDashboard, + .courseComponent: popToCourseContainerView(animated: false) default: break @@ -136,7 +142,7 @@ extension Router: DeepLinkRouter { self.hostCourseContainerView?.rootView.viewModel.selection = CourseTab.course.rawValue case .courseVideos: self.hostCourseContainerView?.rootView.viewModel.selection = CourseTab.videos.rawValue - case .courseDates: + case .courseDates, .courseComponent: self.hostCourseContainerView?.rootView.viewModel.selection = CourseTab.dates.rawValue case .discussions, .discussionTopic, .discussionPost, .discussionComment: self.hostCourseContainerView?.rootView.viewModel.selection = CourseTab.discussion.rawValue @@ -335,6 +341,11 @@ public class DeepLinkRouterMock: BaseRouterMock, DeepLinkRouter { courseDetails: CourseDetails, completion: @escaping () -> Void ) {} + public func showCourseComponent( + componentID: String, + courseStructure: CourseStructure, + blockLink: String + ) {} public func showAnnouncement( courseDetails: CourseDetails, updates: [CourseUpdate] From 8232c81096ebcaa5bda5dfe386df02cae7096e37 Mon Sep 17 00:00:00 2001 From: forgotvas Date: Thu, 4 Apr 2024 12:31:18 +0300 Subject: [PATCH 122/136] fix: added back navigation menu --- .../Presentation/Login/SignInView.swift | 12 ++--- .../Registration/SignUpView.swift | 13 ++--- .../Presentation/Startup/StartupView.swift | 1 + .../Authorization/SwiftGen/Strings.swift | 2 + .../en.lproj/Localizable.strings | 1 + .../uk.lproj/Localizable.strings | 1 + Core/Core.xcodeproj/project.pbxproj | 8 +++ .../Core/View/Base/BackNavigationButton.swift | 51 +++++++++++++++++++ .../Base/BackNavigationButtonViewModel.swift | 41 +++++++++++++++ Core/Core/View/Base/NavigationBar.swift | 12 +---- OpenEdX/DI/ScreenAssembly.swift | 4 ++ OpenEdX/Router.swift | 23 ++++++++- .../EditProfile/EditProfileView.swift | 8 +-- 13 files changed, 148 insertions(+), 29 deletions(-) create mode 100644 Core/Core/View/Base/BackNavigationButton.swift create mode 100644 Core/Core/View/Base/BackNavigationButtonViewModel.swift diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index f56698803..972ebf8b8 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -34,14 +34,14 @@ public struct SignInView: View { }.frame(maxWidth: .infinity, maxHeight: 200) if viewModel.config.features.startupScreenEnabled { VStack { - Button(action: { viewModel.router.back() }, label: { - CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) - .backButtonStyle(color: Theme.Colors.loginNavigationText) - }) - .foregroundColor(Theme.Colors.styledButtonText) + BackNavigationButton( + color: Theme.Colors.loginNavigationText, + action: { + viewModel.router.back() + } + ) .padding(.leading, isHorizontal ? 48 : 0) .padding(.top, 11) - .accessibilityIdentifier("back_button") }.frame(maxWidth: .infinity, alignment: .topLeading) .padding(.top, isHorizontal ? 20 : 0) diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index fb9614ceb..29592d703 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -8,6 +8,7 @@ import SwiftUI import Core import Theme +import Swinject public struct SignUpView: View { @@ -45,13 +46,13 @@ public struct SignUpView: View { .accessibilityIdentifier("register_text") } VStack { - Button(action: { viewModel.router.back() }, label: { - CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) - .backButtonStyle(color: Theme.Colors.loginNavigationText) - }) - .foregroundColor(Theme.Colors.styledButtonText) + BackNavigationButton( + color: Theme.Colors.loginNavigationText, + action: { + viewModel.router.back() + } + ) .padding(.leading, isHorizontal ? 48 : 0) - .accessibilityIdentifier("back_button") }.frame(minWidth: 0, maxWidth: .infinity, diff --git a/Authorization/Authorization/Presentation/Startup/StartupView.swift b/Authorization/Authorization/Presentation/Startup/StartupView.swift index b8b3db370..a13c3e3c8 100644 --- a/Authorization/Authorization/Presentation/Startup/StartupView.swift +++ b/Authorization/Authorization/Presentation/Startup/StartupView.swift @@ -118,6 +118,7 @@ public struct StartupView: View { } .frameLimit() } + .navigationTitle(AuthLocalization.Startup.title) .hideNavigationBar() .padding(.all, isHorizontal ? 1 : 0) .background(Theme.Colors.background.ignoresSafeArea(.all)) diff --git a/Authorization/Authorization/SwiftGen/Strings.swift b/Authorization/Authorization/SwiftGen/Strings.swift index a977c2cae..d139b4a0e 100644 --- a/Authorization/Authorization/SwiftGen/Strings.swift +++ b/Authorization/Authorization/SwiftGen/Strings.swift @@ -103,6 +103,8 @@ public enum AuthLocalization { public static let searchPlaceholder = AuthLocalization.tr("Localizable", "STARTUP.SEARCH_PLACEHOLDER", fallback: "Search our 3000+ courses") /// What do you want to learn? public static let searchTitle = AuthLocalization.tr("Localizable", "STARTUP.SEARCH_TITLE", fallback: "What do you want to learn?") + /// Start + public static let title = AuthLocalization.tr("Localizable", "STARTUP.TITLE", fallback: "Start") } } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length diff --git a/Authorization/Authorization/en.lproj/Localizable.strings b/Authorization/Authorization/en.lproj/Localizable.strings index 9b776c4b9..f15da07ce 100644 --- a/Authorization/Authorization/en.lproj/Localizable.strings +++ b/Authorization/Authorization/en.lproj/Localizable.strings @@ -49,3 +49,4 @@ accordance with the [Privacy Policy.](%@)"; "STARTUP.SEARCH_TITLE" = "What do you want to learn?"; "STARTUP.SEARCH_PLACEHOLDER" = "Search our 3000+ courses"; "STARTUP.EXPLORE_ALL_COURSES" = "Explore all courses"; +"STARTUP.TITLE" = "Start"; diff --git a/Authorization/Authorization/uk.lproj/Localizable.strings b/Authorization/Authorization/uk.lproj/Localizable.strings index e341b0ebd..00ab874ce 100644 --- a/Authorization/Authorization/uk.lproj/Localizable.strings +++ b/Authorization/Authorization/uk.lproj/Localizable.strings @@ -46,3 +46,4 @@ accordance with the [Privacy Policy.](%@)"; "STARTUP.SEARCH_TITLE" = "What do you want to learn?"; "STARTUP.SEARCH_PLACEHOLDER" = "Search our 3000+ courses"; "STARTUP.EXPLORE_ALL_COURSES" = "Explore all courses"; +"STARTUP.TITLE" = "Start"; diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index cf55fd89a..d17b77529 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -97,6 +97,8 @@ 06619EAD2B90918B001FAADE /* ReadabilityInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06619EAC2B90918B001FAADE /* ReadabilityInjection.swift */; }; 06619EAF2B973B25001FAADE /* AccessibilityInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06619EAE2B973B25001FAADE /* AccessibilityInjection.swift */; }; 06BEEA0E2B6A55C500D25A97 /* ColorInversionInjection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06BEEA0D2B6A55C500D25A97 /* ColorInversionInjection.swift */; }; + 06DEA4A32BBD66A700110D20 /* BackNavigationButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06DEA4A22BBD66A700110D20 /* BackNavigationButton.swift */; }; + 06DEA4A52BBD66D700110D20 /* BackNavigationButtonViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06DEA4A42BBD66D700110D20 /* BackNavigationButtonViewModel.swift */; }; 070019A528F6F17900D5FC78 /* Data_Media.swift in Sources */ = {isa = PBXBuildFile; fileRef = 070019A428F6F17900D5FC78 /* Data_Media.swift */; }; 070019AC28F6FD0100D5FC78 /* CourseDetailBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 070019AB28F6FD0100D5FC78 /* CourseDetailBlock.swift */; }; 070019AE28F701B200D5FC78 /* Certificate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 070019AD28F701B200D5FC78 /* Certificate.swift */; }; @@ -275,6 +277,8 @@ 06619EAC2B90918B001FAADE /* ReadabilityInjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadabilityInjection.swift; sourceTree = ""; }; 06619EAE2B973B25001FAADE /* AccessibilityInjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccessibilityInjection.swift; sourceTree = ""; }; 06BEEA0D2B6A55C500D25A97 /* ColorInversionInjection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ColorInversionInjection.swift; sourceTree = ""; }; + 06DEA4A22BBD66A700110D20 /* BackNavigationButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackNavigationButton.swift; sourceTree = ""; }; + 06DEA4A42BBD66D700110D20 /* BackNavigationButtonViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackNavigationButtonViewModel.swift; sourceTree = ""; }; 070019A428F6F17900D5FC78 /* Data_Media.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_Media.swift; sourceTree = ""; }; 070019AB28F6FD0100D5FC78 /* CourseDetailBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDetailBlock.swift; sourceTree = ""; }; 070019AD28F701B200D5FC78 /* Certificate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Certificate.swift; sourceTree = ""; }; @@ -725,6 +729,8 @@ BA8FA6672AD59A5700EA029A /* SocialAuthButton.swift */, 02E93F862AEBAED4006C4750 /* AppReview */, BA981BCF2B91ED50005707C2 /* FullScreenProgressView.swift */, + 06DEA4A22BBD66A700110D20 /* BackNavigationButton.swift */, + 06DEA4A42BBD66D700110D20 /* BackNavigationButtonViewModel.swift */, ); path = Base; sourceTree = ""; @@ -1062,6 +1068,7 @@ 06619EAF2B973B25001FAADE /* AccessibilityInjection.swift in Sources */, BAFB99822B0E2354007D09F9 /* FacebookConfig.swift in Sources */, 027BD3B32909475900392132 /* Publishers+KeyboardState.swift in Sources */, + 06DEA4A32BBD66A700110D20 /* BackNavigationButton.swift in Sources */, 0727877D28D25212002E9142 /* ProgressBar.swift in Sources */, BA981BD02B91ED50005707C2 /* FullScreenProgressView.swift in Sources */, 0236961F28F9A2F600EEF206 /* AuthEndpoint.swift in Sources */, @@ -1142,6 +1149,7 @@ 072787B628D37A0E002E9142 /* Validator.swift in Sources */, 0236961D28F9A2D200EEF206 /* Data_AuthResponse.swift in Sources */, A5F4E7B52B61544A00ACD166 /* BrazeConfig.swift in Sources */, + 06DEA4A52BBD66D700110D20 /* BackNavigationButtonViewModel.swift in Sources */, 02AFCC182AEFDB24000360F0 /* ThirdPartyMailClient.swift in Sources */, 0233D5712AF13EC800BAC8BD /* SelectMailClientView.swift in Sources */, BAFB99842B0E282E007D09F9 /* MicrosoftConfig.swift in Sources */, diff --git a/Core/Core/View/Base/BackNavigationButton.swift b/Core/Core/View/Base/BackNavigationButton.swift new file mode 100644 index 000000000..9ce6682a0 --- /dev/null +++ b/Core/Core/View/Base/BackNavigationButton.swift @@ -0,0 +1,51 @@ +// +// BackNavigationButton.swift +// Core +// +// Created by Vadim Kuznetsov on 3.04.24. +// + +import SwiftUI +import Theme + +public struct BackNavigationButton: View { + @StateObject var viewModel = BackNavigationButtonViewModel() + private let color: Color + private let action: (() -> Void)? + + public init( + color: Color = Theme.Colors.accentXColor, + action: (() -> Void)? = nil + ) { + self.color = color + self.action = action + } + + public var body: some View { + Menu { + ForEach(viewModel.items) { item in + Button(item.title) { + viewModel.navigateTo(item: item) + } + } + } label: { + CoreAssets.arrowLeft.swiftUIImage + .backButtonStyle(color: color) + } primaryAction: { + action?() + } + .foregroundColor(Theme.Colors.styledButtonText) + .accessibilityIdentifier("back_button") + .onAppear { + viewModel.loadItems() + } + + } +} +#if DEBUG +struct BackNavigationButton_Previews: PreviewProvider { + static var previews: some View { + BackNavigationButton() + } +} +#endif diff --git a/Core/Core/View/Base/BackNavigationButtonViewModel.swift b/Core/Core/View/Base/BackNavigationButtonViewModel.swift new file mode 100644 index 000000000..00c4b5d76 --- /dev/null +++ b/Core/Core/View/Base/BackNavigationButtonViewModel.swift @@ -0,0 +1,41 @@ +// +// BackNavigationButtonViewModel.swift +// Core +// +// Created by Vadim Kuznetsov on 3.04.24. +// + +import Swinject +import UIKit + +public protocol BackNavigationProtocol { + func getBackMenuItems() -> [BackNavigationMenuItem] + func navigateTo(item: BackNavigationMenuItem) +} + +public struct BackNavigationMenuItem: Identifiable { + public var id: Int + public var title: String + + public init(id: Int, title: String) { + self.id = id + self.title = title + } +} + +class BackNavigationButtonViewModel: ObservableObject { + private let helper: BackNavigationProtocol + @Published var items: [BackNavigationMenuItem] = [] + + init() { + self.helper = Container.shared.resolve(BackNavigationProtocol.self)! + } + + func loadItems() { + self.items = helper.getBackMenuItems() + } + + func navigateTo(item: BackNavigationMenuItem) { + helper.navigateTo(item: item) + } +} diff --git a/Core/Core/View/Base/NavigationBar.swift b/Core/Core/View/Base/NavigationBar.swift index 95ce1dae0..062aed6d5 100644 --- a/Core/Core/View/Base/NavigationBar.swift +++ b/Core/Core/View/Base/NavigationBar.swift @@ -52,16 +52,8 @@ public struct NavigationBar: View { .padding(.horizontal, 24) if leftButton { VStack { - Button(action: { - leftButtonAction?() - }, label: { - CoreAssets.arrowLeft.swiftUIImage - .backButtonStyle(color: leftButtonColor) - .padding(8) - }) - .foregroundColor(Theme.Colors.styledButtonText) - .accessibilityIdentifier("back_button") - + BackNavigationButton(color: leftButtonColor, action: leftButtonAction) + .padding(8) }.frame(minWidth: 0, maxWidth: .infinity, alignment: .topLeading) diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 4dbeba697..4982c0c27 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -436,6 +436,10 @@ class ScreenAssembly: Assembly { config: r.resolve(ConfigProtocol.self)! ) } + + container.register(BackNavigationProtocol.self) { r in + r.resolve(Router.self)! + } } } // swiftlint:enable function_body_length type_body_length diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 687e93cd8..ec77a8a8b 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -26,7 +26,8 @@ public class Router: AuthorizationRouter, ProfileRouter, DashboardRouter, CourseRouter, - DiscussionRouter { + DiscussionRouter, + BackNavigationProtocol { public var container: Container @@ -725,4 +726,24 @@ public class Router: AuthorizationRouter, navigationController.pushViewController(controller, animated: true) } } + +// MARK: BackNavigationProtocol +extension Router { + public func getBackMenuItems() -> [BackNavigationMenuItem] { + var viewControllers = navigationController.viewControllers + viewControllers.removeLast() + var items: [BackNavigationMenuItem] = [] + for (index, controller) in viewControllers.enumerated() { + let title = controller.navigationItem.title ?? controller.title ?? "" + let item = BackNavigationMenuItem(id: index, title: title) + items.append(item) + } + return items + } + + public func navigateTo(item: BackNavigationMenuItem) { + let viewControllers = Array(navigationController.viewControllers[0 ... item.id]) + navigationController.setViewControllers(viewControllers, animated: true) + } +} // swiftlint:enable file_length type_body_length diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift index 86927376f..62fbccaf0 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift @@ -212,13 +212,9 @@ public struct EditProfileView: View { .navigationTitle(ProfileLocalization.editProfile) .toolbar { ToolbarItem(placement: .navigationBarLeading, content: { - Button(action: { + BackNavigationButton(color: Theme.Colors.accentColor) { viewModel.backButtonTapped() - }, label: { - CoreAssets.arrowLeft.swiftUIImage - .renderingMode(.template) - .foregroundColor(Theme.Colors.accentColor) - }) + } }) ToolbarItem(placement: .navigationBarTrailing, content: { Button(action: { From 70e2c6908bbb23f2799c90f52ea50618454331f5 Mon Sep 17 00:00:00 2001 From: forgotvas Date: Thu, 4 Apr 2024 14:08:27 +0300 Subject: [PATCH 123/136] chore: align popup to be above the button --- .../Core/View/Base/BackNavigationButton.swift | 72 +++++++++++++++---- .../DeleteAccount/DeleteAccountView.swift | 10 ++- 2 files changed, 67 insertions(+), 15 deletions(-) diff --git a/Core/Core/View/Base/BackNavigationButton.swift b/Core/Core/View/Base/BackNavigationButton.swift index 9ce6682a0..671cd05fe 100644 --- a/Core/Core/View/Base/BackNavigationButton.swift +++ b/Core/Core/View/Base/BackNavigationButton.swift @@ -8,6 +8,60 @@ import SwiftUI import Theme +class BackButton: UIButton { + override func menuAttachmentPoint(for configuration: UIContextMenuConfiguration) -> CGPoint { + return .zero + } +} + +public struct BackNavigationButtonRepresentable: UIViewRepresentable { + @ObservedObject var viewModel: BackNavigationButtonViewModel + var action: (() -> Void)? + var color: Color + + init(action: (() -> Void)? = nil, color: Color, viewModel: BackNavigationButtonViewModel) { + self.viewModel = viewModel + self.action = action + self.color = color + } + + public func makeUIView(context: Context) -> UIButton { + let button = BackButton(type: .custom) + let image = CoreAssets.arrowLeft.image.withRenderingMode(.alwaysTemplate) + button.setImage(image, for: .normal) + button.tintColor = UIColor(color) + button.contentHorizontalAlignment = .leading + button.addTarget(context.coordinator, action: #selector(Coordinator.buttonAction), for: .touchUpInside) + return button + } + + public func updateUIView(_ button: UIButton, context: Context) { + var actions: [UIAction] = [] + for item in viewModel.items { + let action = UIAction(title: item.title) {[weak viewModel] _ in + viewModel?.navigateTo(item: item) + } + actions.append(action) + } + button.menu = UIMenu(title: "", children: actions) + } + + public func makeCoordinator() -> Coordinator { + Coordinator(action: action) + } + + public class Coordinator: NSObject { + var action: (() -> Void)? + init(action: (() -> Void)?) { + self.action = action + } + + @objc func buttonAction() { + action?() + } + } +} + public struct BackNavigationButton: View { @StateObject var viewModel = BackNavigationButtonViewModel() private let color: Color @@ -22,19 +76,11 @@ public struct BackNavigationButton: View { } public var body: some View { - Menu { - ForEach(viewModel.items) { item in - Button(item.title) { - viewModel.navigateTo(item: item) - } - } - } label: { - CoreAssets.arrowLeft.swiftUIImage - .backButtonStyle(color: color) - } primaryAction: { - action?() - } - .foregroundColor(Theme.Colors.styledButtonText) + BackNavigationButtonRepresentable(action: action, color: color, viewModel: viewModel) + .frame(height: 24) + .padding(.horizontal, 8) + .offset(y: -10) + .foregroundColor(color) .accessibilityIdentifier("back_button") .onAppear { viewModel.loadItems() diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift index 3f6fc6fe1..b18c3f4ff 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift @@ -144,9 +144,15 @@ public struct DeleteAccountView: View { alignment: .top) .padding(.top, 8) .navigationBarHidden(false) - .navigationBarBackButtonHidden(false) + .navigationBarBackButtonHidden(true) .navigationTitle(ProfileLocalization.DeleteAccount.title) - + .toolbar { + ToolbarItem(placement: .navigationBarLeading) { + BackNavigationButton(color: Theme.Colors.accentColor) { + viewModel.router.back() + } + } + } // MARK: - Error Alert if viewModel.showError { VStack { From f60ae3eb2cbf10e9684a796fece7b972a8a4f18e Mon Sep 17 00:00:00 2001 From: forgotvas Date: Thu, 4 Apr 2024 14:52:59 +0300 Subject: [PATCH 124/136] chore: button alignment --- .../Authorization/Presentation/Login/SignInView.swift | 1 + .../Presentation/Registration/SignUpView.swift | 1 + Core/Core/Extensions/ViewExtension.swift | 11 ++++++++--- Core/Core/View/Base/BackNavigationButton.swift | 8 ++------ Core/Core/View/Base/NavigationBar.swift | 1 + 5 files changed, 13 insertions(+), 9 deletions(-) diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index 972ebf8b8..209da1912 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -40,6 +40,7 @@ public struct SignInView: View { viewModel.router.back() } ) + .backViewStyle() .padding(.leading, isHorizontal ? 48 : 0) .padding(.top, 11) diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index 29592d703..0f1263197 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -52,6 +52,7 @@ public struct SignUpView: View { viewModel.router.back() } ) + .backViewStyle() .padding(.leading, isHorizontal ? 48 : 0) }.frame(minWidth: 0, diff --git a/Core/Core/Extensions/ViewExtension.swift b/Core/Core/Extensions/ViewExtension.swift index c0d26e7ea..4d98df77d 100644 --- a/Core/Core/Extensions/ViewExtension.swift +++ b/Core/Core/Extensions/ViewExtension.swift @@ -230,6 +230,13 @@ public extension View { func onFirstAppear(_ action: @escaping () -> Void) -> some View { modifier(FirstAppear(action: action)) } + + func backViewStyle(topPadding: CGFloat = -10) -> some View { + return self + .frame(height: 24) + .padding(.horizontal, 8) + .offset(y: topPadding) + } } public extension View { @@ -300,10 +307,8 @@ public extension Image { .renderingMode(.template) .resizable() .scaledToFit() - .frame(height: 24) - .padding(.horizontal, 8) - .offset(y: topPadding) .foregroundColor(color) + .backViewStyle(topPadding: topPadding) } } diff --git a/Core/Core/View/Base/BackNavigationButton.swift b/Core/Core/View/Base/BackNavigationButton.swift index 671cd05fe..030c67269 100644 --- a/Core/Core/View/Base/BackNavigationButton.swift +++ b/Core/Core/View/Base/BackNavigationButton.swift @@ -32,6 +32,7 @@ public struct BackNavigationButtonRepresentable: UIViewRepresentable { button.tintColor = UIColor(color) button.contentHorizontalAlignment = .leading button.addTarget(context.coordinator, action: #selector(Coordinator.buttonAction), for: .touchUpInside) + button.accessibilityIdentifier = "back_button" return button } @@ -76,12 +77,7 @@ public struct BackNavigationButton: View { } public var body: some View { - BackNavigationButtonRepresentable(action: action, color: color, viewModel: viewModel) - .frame(height: 24) - .padding(.horizontal, 8) - .offset(y: -10) - .foregroundColor(color) - .accessibilityIdentifier("back_button") + BackNavigationButtonRepresentable(action: action, color: color, viewModel: viewModel) .onAppear { viewModel.loadItems() } diff --git a/Core/Core/View/Base/NavigationBar.swift b/Core/Core/View/Base/NavigationBar.swift index 062aed6d5..d4f07240c 100644 --- a/Core/Core/View/Base/NavigationBar.swift +++ b/Core/Core/View/Base/NavigationBar.swift @@ -54,6 +54,7 @@ public struct NavigationBar: View { VStack { BackNavigationButton(color: leftButtonColor, action: leftButtonAction) .padding(8) + .backViewStyle() }.frame(minWidth: 0, maxWidth: .infinity, alignment: .topLeading) From 2d6f23f1d157600e0f16c06dbb62542e0cbd3245 Mon Sep 17 00:00:00 2001 From: forgotvas Date: Thu, 4 Apr 2024 15:57:59 +0300 Subject: [PATCH 125/136] chore: align button --- .../Profile/Presentation/DeleteAccount/DeleteAccountView.swift | 1 + Profile/Profile/Presentation/EditProfile/EditProfileView.swift | 1 + 2 files changed, 2 insertions(+) diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift index b18c3f4ff..c2f5dc7fb 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift @@ -151,6 +151,7 @@ public struct DeleteAccountView: View { BackNavigationButton(color: Theme.Colors.accentColor) { viewModel.router.back() } + .offset(x: -8, y: -1.5) } } // MARK: - Error Alert diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift index 62fbccaf0..e1fc71b27 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift @@ -215,6 +215,7 @@ public struct EditProfileView: View { BackNavigationButton(color: Theme.Colors.accentColor) { viewModel.backButtonTapped() } + .offset(x: -8, y: -1.5) }) ToolbarItem(placement: .navigationBarTrailing, content: { Button(action: { From 7765405251cbea370774e06c3b127c786f4f9f4b Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Fri, 5 Apr 2024 12:14:43 +0200 Subject: [PATCH 126/136] fix: rolled back test value (#388) --- OpenEdX/Router.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 687e93cd8..6f190a76a 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -138,7 +138,7 @@ public class Router: AuthorizationRouter, connectivity.isInternetAvaliable else { return } let vm = AppReviewViewModel(config: config, storage: storage, analytics: analytics) - if true { + if vm.shouldShowRatingView() { presentView( transitionStyle: .crossDissolve, view: AppReviewView(viewModel: vm) From 9ab04e5089e024e5a4d3b804a5642166763a2236 Mon Sep 17 00:00:00 2001 From: forgotvas Date: Mon, 8 Apr 2024 18:47:46 +0300 Subject: [PATCH 127/136] fix: back button on discussion --- Core/Core/View/Base/BackNavigationButton.swift | 2 +- Core/Core/View/Base/NavigationBar.swift | 15 ++++++++------- .../Comments/Responses/ResponsesView.swift | 13 ++++++++++++- .../Presentation/Comments/Thread/ThreadView.swift | 13 ++++++++++++- .../DiscussionSearchTopicsView.swift | 5 +++-- .../EditProfile/EditProfileView.swift | 13 ++++++++----- 6 files changed, 44 insertions(+), 17 deletions(-) diff --git a/Core/Core/View/Base/BackNavigationButton.swift b/Core/Core/View/Base/BackNavigationButton.swift index 030c67269..001f5d340 100644 --- a/Core/Core/View/Base/BackNavigationButton.swift +++ b/Core/Core/View/Base/BackNavigationButton.swift @@ -77,7 +77,7 @@ public struct BackNavigationButton: View { } public var body: some View { - BackNavigationButtonRepresentable(action: action, color: color, viewModel: viewModel) + BackNavigationButtonRepresentable(action: action, color: color, viewModel: viewModel) .onAppear { viewModel.loadItems() } diff --git a/Core/Core/View/Base/NavigationBar.swift b/Core/Core/View/Base/NavigationBar.swift index d4f07240c..8815cf2b8 100644 --- a/Core/Core/View/Base/NavigationBar.swift +++ b/Core/Core/View/Base/NavigationBar.swift @@ -51,13 +51,14 @@ public struct NavigationBar: View { } .padding(.horizontal, 24) if leftButton { - VStack { - BackNavigationButton(color: leftButtonColor, action: leftButtonAction) - .padding(8) - .backViewStyle() - }.frame(minWidth: 0, - maxWidth: .infinity, - alignment: .topLeading) + VStack { + BackNavigationButton(color: leftButtonColor, action: leftButtonAction) + .padding(8) + .backViewStyle() + } + .frame(minWidth: 0, + maxWidth: .infinity, + alignment: .topLeading) } if rightButtonType != nil { diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index 80c9cb71f..a9b8ef2cb 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -208,8 +208,19 @@ public struct ResponsesView: View { } .ignoresSafeArea(.all, edges: .horizontal) .navigationBarHidden(false) - .navigationBarBackButtonHidden(false) + .navigationBarBackButtonHidden(true) .navigationTitle(title) + .toolbar { + ToolbarItem( + placement: .navigationBarLeading, + content: { + BackNavigationButton(color: Theme.Colors.accentColor) { + viewModel.router.back() + } + .offset(x: -8, y: -1.5) + } + ) + } .edgesIgnoringSafeArea(.bottom) .background( Theme.Colors.background diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index 075685d92..2c7632730 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -226,8 +226,19 @@ public struct ThreadView: View { } .ignoresSafeArea(.all, edges: .horizontal) .navigationBarHidden(false) - .navigationBarBackButtonHidden(false) + .navigationBarBackButtonHidden(true) .navigationTitle(title) + .toolbar { + ToolbarItem( + placement: .navigationBarLeading, + content: { + BackNavigationButton(color: Theme.Colors.accentColor) { + viewModel.router.back() + } + .offset(x: -8, y: -1.5) + } + ) + } .onFirstAppear { Task { await viewModel.getPosts(thread: thread, page: 1) diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift index de306714b..c9fd88dd7 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift @@ -146,8 +146,11 @@ public struct DiscussionSearchTopicsView: View { } } } + .background(Theme.Colors.background.ignoresSafeArea()) + .avoidKeyboard(dismissKeyboardByTap: true) .navigationBarBackButtonHidden(true) .navigationBarHidden(true) + .navigationTitle(DiscussionLocalization.search) .onAppear { DispatchQueue.main.asyncAfter(deadline: .now()) { withAnimation(.easeIn(duration: 0.3)) { @@ -155,8 +158,6 @@ public struct DiscussionSearchTopicsView: View { } } } - .background(Theme.Colors.background.ignoresSafeArea()) - .addTapToEndEditing(isForced: true) } } diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift index e1fc71b27..9e2fe176f 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift @@ -211,12 +211,15 @@ public struct EditProfileView: View { .navigationBarBackButtonHidden(true) .navigationTitle(ProfileLocalization.editProfile) .toolbar { - ToolbarItem(placement: .navigationBarLeading, content: { - BackNavigationButton(color: Theme.Colors.accentColor) { - viewModel.backButtonTapped() + ToolbarItem( + placement: .navigationBarLeading, + content: { + BackNavigationButton(color: Theme.Colors.accentColor) { + viewModel.backButtonTapped() + } + .offset(x: -8, y: -1.5) } - .offset(x: -8, y: -1.5) - }) + ) ToolbarItem(placement: .navigationBarTrailing, content: { Button(action: { if viewModel.isChanged { From f2d511472808e9bb668924d258e8392b640c195a Mon Sep 17 00:00:00 2001 From: forgotvas Date: Mon, 8 Apr 2024 19:38:22 +0300 Subject: [PATCH 128/136] chore: fix back button for SearchView --- .../Discovery/Presentation/NativeDiscovery/SearchView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift index 065147d95..265631697 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift @@ -174,7 +174,7 @@ public struct SearchView: View { viewModel.searchText = "" } .background(Theme.Colors.background.ignoresSafeArea()) - .addTapToEndEditing(isForced: true) + .avoidKeyboard(dismissKeyboardByTap: true) } } From 955a8e32c5045956ef7f253c8768f4ab868fcc3b Mon Sep 17 00:00:00 2001 From: forgotvas Date: Mon, 8 Apr 2024 20:16:18 +0300 Subject: [PATCH 129/136] chore: removed useless import --- .../Authorization/Presentation/Registration/SignUpView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index 0f1263197..4c6e154c0 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -8,7 +8,6 @@ import SwiftUI import Core import Theme -import Swinject public struct SignUpView: View { From 16b17b6b86b8f215c00e6dcb7b048a2d6ad2727b Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Wed, 10 Apr 2024 16:44:33 +0300 Subject: [PATCH 130/136] feat: [FC-0047] move the certificate view from the banner to the message section (#387) * feat: move the certificate view from the banner to the message section * refactor: Rename badge to certificateBadge and improve code structure - Rename `badge` to `certificateBadge` on Assets to enhance semantic clarity. - Refactor conditional unwrapping of `certificate.url` in viewModel for improved code readability. Add SFPro-Light font to whitelabel configuration * fix: update Theming_implementation add light font --- .../certificateBadge.imageset/Contents.json | 15 ++++ .../certificateIcon.svg | 3 + Core/Core/SwiftGen/Assets.swift | 1 + Course/Course.xcodeproj/project.pbxproj | 12 ++++ .../Outline/CourseOutlineView.swift | 69 ++++++++----------- .../MessageSectionView.swift | 66 ++++++++++++++++++ Course/Course/SwiftGen/Strings.swift | 14 ++-- Course/Course/en.lproj/Localizable.strings | 3 +- Course/Course/uk.lproj/Localizable.strings | 3 +- Documentation/Theming_implementation.md | 2 + Theme/Theme/Fonts/FontParser.swift | 2 +- Theme/Theme/Fonts/fonts.json | 1 + Theme/Theme/Theme.swift | 1 + config_script/whitelabel.py | 3 +- 14 files changed, 143 insertions(+), 52 deletions(-) create mode 100644 Core/Core/Assets.xcassets/certificateBadge.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/certificateBadge.imageset/certificateIcon.svg create mode 100644 Course/Course/Presentation/Subviews/MessageSectionView/MessageSectionView.swift diff --git a/Core/Core/Assets.xcassets/certificateBadge.imageset/Contents.json b/Core/Core/Assets.xcassets/certificateBadge.imageset/Contents.json new file mode 100644 index 000000000..30dcc384e --- /dev/null +++ b/Core/Core/Assets.xcassets/certificateBadge.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "certificateIcon.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Core/Core/Assets.xcassets/certificateBadge.imageset/certificateIcon.svg b/Core/Core/Assets.xcassets/certificateBadge.imageset/certificateIcon.svg new file mode 100644 index 000000000..fda75f3b0 --- /dev/null +++ b/Core/Core/Assets.xcassets/certificateBadge.imageset/certificateIcon.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/SwiftGen/Assets.swift b/Core/Core/SwiftGen/Assets.swift index 2252597b1..17b1fad70 100644 --- a/Core/Core/SwiftGen/Assets.swift +++ b/Core/Core/SwiftGen/Assets.swift @@ -91,6 +91,7 @@ public enum CoreAssets { public static let arrowLeft = ImageAsset(name: "arrowLeft") public static let arrowRight16 = ImageAsset(name: "arrowRight16") public static let certificate = ImageAsset(name: "certificate") + public static let certificateBadge = ImageAsset(name: "certificateBadge") public static let check = ImageAsset(name: "check") public static let checkEmail = ImageAsset(name: "checkEmail") public static let clearInput = ImageAsset(name: "clearInput") diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index 6d7eab45a..3026925aa 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -45,6 +45,7 @@ 02B6B3BC28E1D14F00232911 /* CourseRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B6B3BB28E1D14F00232911 /* CourseRepository.swift */; }; 02B6B3BE28E1D15C00232911 /* CourseEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B6B3BD28E1D15C00232911 /* CourseEndpoint.swift */; }; 02B6B3C928E1E68100232911 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02B6B3C828E1E68100232911 /* Core.framework */; }; + 02D4FC2E2BBD7C9C00C47748 /* MessageSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D4FC2D2BBD7C9C00C47748 /* MessageSectionView.swift */; }; 02F0144F28F46474002E513D /* CourseContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F0144E28F46474002E513D /* CourseContainerView.swift */; }; 02F0145728F4A2FF002E513D /* CourseContainerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F0145628F4A2FF002E513D /* CourseContainerViewModel.swift */; }; 02F066E829DC71750073E13B /* SubtittlesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F066E729DC71750073E13B /* SubtittlesView.swift */; }; @@ -141,6 +142,7 @@ 02B6B3BD28E1D15C00232911 /* CourseEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseEndpoint.swift; sourceTree = ""; }; 02B6B3C228E1DCD100232911 /* CourseDetails.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDetails.swift; sourceTree = ""; }; 02B6B3C828E1E68100232911 /* Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 02D4FC2D2BBD7C9C00C47748 /* MessageSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSectionView.swift; sourceTree = ""; }; 02ED50CF29A64BB6008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02F0144E28F46474002E513D /* CourseContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseContainerView.swift; sourceTree = ""; }; 02F0145628F4A2FF002E513D /* CourseContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseContainerViewModel.swift; sourceTree = ""; }; @@ -362,6 +364,14 @@ name = Frameworks; sourceTree = ""; }; + 02D4FC2C2BBD7C7500C47748 /* MessageSectionView */ = { + isa = PBXGroup; + children = ( + 02D4FC2D2BBD7C9C00C47748 /* MessageSectionView.swift */, + ); + path = MessageSectionView; + sourceTree = ""; + }; 02DFC65029ACC20A00EA4BB9 /* Handouts */ = { isa = PBXGroup; children = ( @@ -562,6 +572,7 @@ BAD9CA482B2C88D500DE790A /* Subviews */ = { isa = PBXGroup; children = ( + 02D4FC2C2BBD7C7500C47748 /* MessageSectionView */, BAC0E0D92B32F0A2006B68A9 /* CourseVideoDownloadBarView */, BA58CF622B471047005B102E /* VideoDownloadQualityBarView */, ); @@ -831,6 +842,7 @@ 0270210328E736E700F54332 /* CourseOutlineView.swift in Sources */, 068DDA602B1E198700FF8CCB /* CourseUnitVerticalsDropdownView.swift in Sources */, 022C64E029ADEA9B000F532B /* Data_UpdatesResponse.swift in Sources */, + 02D4FC2E2BBD7C9C00C47748 /* MessageSectionView.swift in Sources */, 02454CA02A2618E70043052A /* YouTubeView.swift in Sources */, 02454CA22A26190A0043052A /* EncodedVideoView.swift in Sources */, 065275352BB1B39C0093BCCA /* PlayerViewControllerHolder.swift in Sources */, diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 54a069049..c22d2219b 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -77,7 +77,8 @@ public struct CourseOutlineView: View { } downloadQualityBars - + certificateView + if let continueWith = viewModel.continueWith, let courseStructure = viewModel.courseStructure, !isVideo { @@ -100,7 +101,7 @@ public struct CourseOutlineView: View { viewModel.trackResumeCourseClicked( blockId: continueBlock?.id ?? "" ) - + if let course = viewModel.courseStructure { viewModel.router.showCourseUnit( courseName: course.displayName, @@ -270,6 +271,33 @@ public struct CourseOutlineView: View { } } } + + @ViewBuilder + private var certificateView: some View { + // MARK: - Course Certificate + if let certificate = viewModel.courseStructure?.certificate, + let url = certificate.url, + url.count > 0 { + MessageSectionView( + title: CourseLocalization.Outline.passedTheCourse(title), + actionTitle: CourseLocalization.Outline.viewCertificate, + action: { + openCertificateView = true + viewModel.trackViewCertificateClicked(courseID: courseID) + } + ) + .padding(.horizontal, 24) + .fullScreenCover( + isPresented: $openCertificateView, + content: { + WebBrowser( + url: url, + pageTitle: CourseLocalization.Outline.certificate + ) + } + ) + } + } private func courseBanner(proxy: GeometryProxy) -> some View { ZStack { @@ -282,43 +310,6 @@ public struct CourseOutlineView: View { .aspectRatio(contentMode: .fill) .frame(maxWidth: proxy.size.width - 12, maxHeight: .infinity) } - - // MARK: - Course Certificate - if let certificate = viewModel.courseStructure?.certificate { - if let url = certificate.url, url.count > 0 { - Theme.Colors.certificateForeground - VStack(alignment: .center, spacing: 8) { - CoreAssets.certificate.swiftUIImage - Text(CourseLocalization.Outline.congratulations) - .multilineTextAlignment(.center) - .font(Theme.Fonts.headlineMedium) - Text(CourseLocalization.Outline.passedTheCourse) - .font(Theme.Fonts.bodyMedium) - .multilineTextAlignment(.center) - StyledButton( - CourseLocalization.Outline.viewCertificate, - action: { - openCertificateView = true - viewModel.trackViewCertificateClicked(courseID: courseID) - }, - isTransparent: true - ) - .frame(width: 141) - .padding(.top, 8) - - .fullScreenCover( - isPresented: $openCertificateView, - content: { - WebBrowser( - url: url, - pageTitle: CourseLocalization.Outline.certificate - ) - }) - }.padding(.horizontal, 24) - .padding(.top, 8) - .foregroundColor(.white) - } - } } .frame(maxHeight: 250) .cornerRadius(12) diff --git a/Course/Course/Presentation/Subviews/MessageSectionView/MessageSectionView.swift b/Course/Course/Presentation/Subviews/MessageSectionView/MessageSectionView.swift new file mode 100644 index 000000000..87e6feb65 --- /dev/null +++ b/Course/Course/Presentation/Subviews/MessageSectionView/MessageSectionView.swift @@ -0,0 +1,66 @@ +// +// MessageSectionView.swift +// Course +// +// Created by  Stepanok Ivan on 03.04.2024. +// + +import SwiftUI +import Core +import Theme + +public struct MessageSectionView: View { + + private let title: String + private let actionTitle: String + private var action: (() -> Void) = {} + + public init( + title: String, + actionTitle: String, + action: @escaping () -> Void + ) { + self.title = title + self.actionTitle = actionTitle + self.action = action + } + + public var body: some View { + HStack(alignment: .top, spacing: 8) { + CoreAssets.certificateBadge.swiftUIImage + .resizable() + .frame(width: 24, height: 24) + .foregroundColor(Theme.Colors.textPrimary) + VStack(alignment: .leading, spacing: 8) { + Text(title) + .font(Theme.Fonts.bodyMicro) + .foregroundColor(Theme.Colors.textPrimary) + + Button(action: { + action() + }, label: { + Text(actionTitle) + .font(Theme.Fonts.bodyMicro) + .underline() + .foregroundColor(Theme.Colors.textPrimary) + }) + } + } + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + .padding(.all, 12) + .background(RoundedRectangle(cornerRadius: 8) + .stroke(lineWidth: 1) + .fill(Theme.Colors.textInputStroke) + ) + } +} + +#Preview { + MessageSectionView( + title: "Congratulations, you have earned this course certificate in “Course Title.”", + actionTitle: "View Certificate", + action: { + } + ) + .loadFonts() +} diff --git a/Course/Course/SwiftGen/Strings.swift b/Course/Course/SwiftGen/Strings.swift index bdcfddabe..78e663241 100644 --- a/Course/Course/SwiftGen/Strings.swift +++ b/Course/Course/SwiftGen/Strings.swift @@ -237,17 +237,17 @@ public enum CourseLocalization { public enum Outline { /// Certificate public static let certificate = CourseLocalization.tr("Localizable", "OUTLINE.CERTIFICATE", fallback: "Certificate") - /// Localizable.strings - /// Course - /// - /// Created by  Stepanok Ivan on 26.09.2022. - public static let congratulations = CourseLocalization.tr("Localizable", "OUTLINE.CONGRATULATIONS", fallback: "Congratulations!") /// This course hasn't started yet. public static let courseHasntStarted = CourseLocalization.tr("Localizable", "OUTLINE.COURSE_HASNT_STARTED", fallback: "This course hasn't started yet.") /// Course videos public static let courseVideos = CourseLocalization.tr("Localizable", "OUTLINE.COURSE_VIDEOS", fallback: "Course videos") - /// You’ve completed the course - public static let passedTheCourse = CourseLocalization.tr("Localizable", "OUTLINE.PASSED_THE_COURSE", fallback: "You’ve completed the course") + /// Localizable.strings + /// Course + /// + /// Created by  Stepanok Ivan on 26.09.2022. + public static func passedTheCourse(_ p1: Any) -> String { + return CourseLocalization.tr("Localizable", "OUTLINE.PASSED_THE_COURSE", String(describing: p1), fallback: "Congratulations, you have earned this course certificate in “%@.”") + } /// View certificate public static let viewCertificate = CourseLocalization.tr("Localizable", "OUTLINE.VIEW_CERTIFICATE", fallback: "View certificate") } diff --git a/Course/Course/en.lproj/Localizable.strings b/Course/Course/en.lproj/Localizable.strings index 5f785ca8d..b22fce96b 100644 --- a/Course/Course/en.lproj/Localizable.strings +++ b/Course/Course/en.lproj/Localizable.strings @@ -6,8 +6,7 @@ */ -"OUTLINE.CONGRATULATIONS" = "Congratulations!"; -"OUTLINE.PASSED_THE_COURSE" = "You’ve completed the course"; +"OUTLINE.PASSED_THE_COURSE" = "Congratulations, you have earned this course certificate in “%@\.”"; "OUTLINE.VIEW_CERTIFICATE" = "View certificate"; "OUTLINE.CERTIFICATE" = "Certificate"; "OUTLINE.COURSE_VIDEOS" = "Course videos"; diff --git a/Course/Course/uk.lproj/Localizable.strings b/Course/Course/uk.lproj/Localizable.strings index 38de538b8..59a57991a 100644 --- a/Course/Course/uk.lproj/Localizable.strings +++ b/Course/Course/uk.lproj/Localizable.strings @@ -6,8 +6,7 @@ */ -"OUTLINE.CONGRATULATIONS" = "Вітаємо!"; -"OUTLINE.PASSED_THE_COURSE" = "Ви пройшли курс"; +"OUTLINE.PASSED_THE_COURSE" = "Вітаємо, ви отримали сертифікат курсу “%@\.“"; "OUTLINE.VIEW_CERTIFICATE" = "Переглянути сертифікат"; "OUTLINE.CERTIFICATE" = "Сертифікат"; "OUTLINE.COURSE_VIDEOS" = "Відео з курсу"; diff --git a/Documentation/Theming_implementation.md b/Documentation/Theming_implementation.md index 77d1b74cf..33e5b577d 100644 --- a/Documentation/Theming_implementation.md +++ b/Documentation/Theming_implementation.md @@ -79,6 +79,7 @@ assets: ### Font The `whitelabel.yaml` configuration may contain the path to a font file, and an existing font in the project will be replaced with this font. This ttf file must contain multiple ttf fonts "merged" into a single ttf file. Font types used in the application: +- light - regular - medium - semiBold @@ -91,6 +92,7 @@ font: project_font_file_path: 'path/to/font/file/in/project/font.ttf' # path to existing ttf font file in project project_font_names_json_path: 'path/to/names/file/in project/fonts.json' # path to existing font names json-file in project font_names: + light: 'FontName-Light' regular: 'FontName-Regular' medium: 'FontName-Medium' semiBold: 'FontName-Semibold' diff --git a/Theme/Theme/Fonts/FontParser.swift b/Theme/Theme/Fonts/FontParser.swift index cd2adbba6..d7ed99b24 100644 --- a/Theme/Theme/Fonts/FontParser.swift +++ b/Theme/Theme/Fonts/FontParser.swift @@ -8,7 +8,7 @@ import Foundation public enum FontIdentifier: String { - case regular, medium, semiBold, bold + case light, regular, medium, semiBold, bold } public class FontParser { diff --git a/Theme/Theme/Fonts/fonts.json b/Theme/Theme/Fonts/fonts.json index 030a32bc3..2af091657 100644 --- a/Theme/Theme/Fonts/fonts.json +++ b/Theme/Theme/Fonts/fonts.json @@ -1,4 +1,5 @@ { + "light": "SFPro-Light", "regular": "SFPro-Regular", "medium": "SFPro-Medium", "semiBold": "SFPro-Semibold", diff --git a/Theme/Theme/Theme.swift b/Theme/Theme/Theme.swift index fba4e1b06..a535683d9 100644 --- a/Theme/Theme/Theme.swift +++ b/Theme/Theme/Theme.swift @@ -197,6 +197,7 @@ public struct Theme { public static let bodyLarge: Font = .custom(fontsParser.fontName(for: .regular), size: 16) public static let bodyMedium: Font = .custom(fontsParser.fontName(for: .regular), size: 14) public static let bodySmall: Font = .custom(fontsParser.fontName(for: .regular), size: 12) + public static let bodyMicro: Font = .custom(fontsParser.fontName(for: .light), size: 11) public static let labelLarge: Font = .custom(fontsParser.fontName(for: .medium), size: 14) public static let labelMedium: Font = .custom(fontsParser.fontName(for: .regular), size: 12) diff --git a/config_script/whitelabel.py b/config_script/whitelabel.py index a6c3b41a3..00eab9f92 100644 --- a/config_script/whitelabel.py +++ b/config_script/whitelabel.py @@ -54,6 +54,7 @@ class WhitelabelApp: project_font_file_path: 'path/to/font/file/in/project/font.ttf' # path to existing ttf font file in project project_font_names_json_path: 'path/to/names/file/in project/fonts.json' # path to existing font names json-file in project font_names: + light: 'SFPro-Light' regular: 'FontName-Regular' medium: 'FontName-Medium' semiBold: 'FontName-Semibold' @@ -545,4 +546,4 @@ def main(): if __name__ == "__main__": main() - \ No newline at end of file + From a9c562d4ff62d1033bdc30d3dd0e479df76510b6 Mon Sep 17 00:00:00 2001 From: Shafqat Muneer Date: Mon, 15 Apr 2024 12:49:51 +0500 Subject: [PATCH 131/136] feat: Calendar Sync Feature Analytics Implementation --- Core/Core/Analytics/CoreAnalytics.swift | 14 +++- Core/Core/Domain/Model/CourseBlockModel.swift | 5 +- Course/Course.xcodeproj/project.pbxproj | 12 ++- Course/Course/Data/CourseRepository.swift | 6 +- .../Model/Data_CourseOutlineResponse.swift | 13 +++- .../CourseCoreModel.xcdatamodel/contents | 3 +- Course/Course/Domain/CourseInteractor.swift | 3 +- .../Course/Presentation/CourseAnalytics.swift | 76 +++++++++++++++++++ .../Dates/CourseDatesViewModel.swift | 50 ++++++++++++ Course/CourseTests/CourseMock.generated.swift | 64 ++++++++++++++++ .../CourseContainerViewModelTests.swift | 27 ++++--- .../Unit/CourseDateViewModelTests.swift | 3 +- OpenEdX/Data/CoursePersistence.swift | 4 +- .../AnalyticsManager/AnalyticsManager.swift | 62 ++++++++++++++- 14 files changed, 317 insertions(+), 25 deletions(-) diff --git a/Core/Core/Analytics/CoreAnalytics.swift b/Core/Core/Analytics/CoreAnalytics.swift index 57f7e49d4..c7d1eca7c 100644 --- a/Core/Core/Analytics/CoreAnalytics.swift +++ b/Core/Core/Analytics/CoreAnalytics.swift @@ -104,6 +104,9 @@ public enum AnalyticsEvent: String { case courseOutlineDiscussionTabClicked = "Course:Discussion Tab" case courseOutlineHandoutsTabClicked = "Course:Handouts Tab" case datesComponentClicked = "Dates:Course Component Clicked" + case datesCalendarSyncToggle = "Dates:CalendarSync Toggle" + case datesCalendarSyncDialogAction = "Dates:CalendarSync Dialog Action" + case datesCalendarSyncSnackbar = "Dates:CalendarSync Snackbar" case plsBannerViewed = "PLS:Banner Viewed" case plsShiftDatesClicked = "PLS:Shift Button Clicked" case plsShiftDatesSuccess = "PLS:Shift Dates Success" @@ -157,7 +160,10 @@ public enum EventBIValue: String { case cookiePolicyClicked = "edx.bi.app.profile.cookie_policy.clicked" case profileDeleteAccountClicked = "edx.bi.app.profile.delete_account.clicked" case userLogout = "edx.bi.app.user.logout" - case datesComponentClicked = "edx.bi.app.coursedates.component.clicked" + case datesComponentClicked = "edx.bi.app.dates.component.clicked" + case datesCalendarSyncToggle = "edx.bi.app.dates.calendar_sync.toggle" + case datesCalendarSyncDialogAction = "edx.bi.app.dates.calendar_sync.dialog_action" + case datesCalendarSyncSnackbar = "edx.bi.app.dates.calendar_sync.snackbar" case plsBannerViewed = "edx.bi.app.dates.pls_banner.viewed" case plsShiftDatesClicked = "edx.bi.app.dates.pls_banner.shift_dates.clicked" case plsShiftDatesSuccess = "edx.bi.app.dates.pls_banner.shift_dates.success" @@ -208,8 +214,6 @@ public struct EventParamKey { public static let topicName = "topic_name" public static let blockID = "block_id" public static let blockName = "block_name" - public static let unitID = "unit_id" - public static let unitName = "unit_name" public static let method = "method" public static let label = "label" public static let coursesCount = "courses_count" @@ -233,6 +237,10 @@ public struct EventParamKey { public static let noOfVideos = "number_of_videos" public static let supported = "supported" public static let conversion = "conversion" + public static let enrollmentMode = "enrollment_mode" + public static let pacing = "pacing" + public static let dialog = "dialog" + public static let snackbar = "snackbar" } public struct EventCategory { diff --git a/Core/Core/Domain/Model/CourseBlockModel.swift b/Core/Core/Domain/Model/CourseBlockModel.swift index 53aa83fe2..cc5c48732 100644 --- a/Core/Core/Domain/Model/CourseBlockModel.swift +++ b/Core/Core/Domain/Model/CourseBlockModel.swift @@ -22,6 +22,7 @@ public struct CourseStructure: Equatable { public var childs: [CourseChapter] public let media: DataLayer.CourseMedia //FIXME Domain model public let certificate: Certificate? + public let isSelfPaced: Bool public init( id: String, @@ -33,7 +34,8 @@ public struct CourseStructure: Equatable { topicID: String? = nil, childs: [CourseChapter], media: DataLayer.CourseMedia, - certificate: Certificate? + certificate: Certificate?, + isSelfPaced: Bool ) { self.id = id self.graded = graded @@ -45,6 +47,7 @@ public struct CourseStructure: Equatable { self.childs = childs self.media = media self.certificate = certificate + self.isSelfPaced = isSelfPaced } public func totalVideosSizeInBytes(downloadQuality: DownloadQuality) -> Int { diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index 3026925aa..3ab8dfe2b 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -147,7 +147,7 @@ 02F0144E28F46474002E513D /* CourseContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseContainerView.swift; sourceTree = ""; }; 02F0145628F4A2FF002E513D /* CourseContainerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseContainerViewModel.swift; sourceTree = ""; }; 02F066E729DC71750073E13B /* SubtittlesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtittlesView.swift; sourceTree = ""; }; - 02F175362A4DAFD20019CD70 /* CourseAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseAnalytics.swift; sourceTree = ""; }; + 02F175362A4DAFD20019CD70 /* CourseAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = CourseAnalytics.swift; path = ../Presentation/CourseAnalytics.swift; sourceTree = ""; }; 02F3BFDC29252E900051930C /* CourseRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseRouter.swift; sourceTree = ""; }; 02F78AEA29E6BCA20038DE30 /* VideoPlayerViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = VideoPlayerViewModelTests.swift; path = CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift; sourceTree = SOURCE_ROOT; }; 02F98A8028F8224200DE94C0 /* Discussion.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Discussion.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -292,6 +292,7 @@ 0289F8F028E1C3510064F8F3 /* Course */ = { isa = PBXGroup; children = ( + 979A6AB92BC3FFF8001B0DE3 /* Analytics */, 02B6B3AD28E1C47100232911 /* SwiftGen */, 02B6B3B828E1D12900232911 /* Data */, 02B6B3B528E1D10700232911 /* Domain */, @@ -394,7 +395,6 @@ BAC0E0DC2B32F0EA006B68A9 /* Downloads */, BAD9CA482B2C88D500DE790A /* Subviews */, 02F3BFDC29252E900051930C /* CourseRouter.swift */, - 02F175362A4DAFD20019CD70 /* CourseAnalytics.swift */, ); path = Presentation; sourceTree = ""; @@ -506,6 +506,14 @@ path = Mock; sourceTree = ""; }; + 979A6AB92BC3FFF8001B0DE3 /* Analytics */ = { + isa = PBXGroup; + children = ( + 02F175362A4DAFD20019CD70 /* CourseAnalytics.swift */, + ); + path = Analytics; + sourceTree = ""; + }; 97CA95212B875EA200A9EDEA /* Views */ = { isa = PBXGroup; children = ( diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index ac980c540..7e4c4cf43 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -137,7 +137,8 @@ public class CourseRepository: CourseRepositoryProtocol { topicID: courseBlock.userViewData?.topicID, childs: childs, media: course.media, - certificate: course.certificate?.domain + certificate: course.certificate?.domain, + isSelfPaced: course.isSelfPaced ) } @@ -346,7 +347,8 @@ And there are various ways of describing it-- call it oral poetry or topicID: courseBlock.userViewData?.topicID, childs: childs, media: course.media, - certificate: course.certificate?.domain + certificate: course.certificate?.domain, + isSelfPaced: course.isSelfPaced ) } diff --git a/Course/Course/Data/Model/Data_CourseOutlineResponse.swift b/Course/Course/Data/Model/Data_CourseOutlineResponse.swift index db1489638..d07c4036e 100644 --- a/Course/Course/Data/Model/Data_CourseOutlineResponse.swift +++ b/Course/Course/Data/Model/Data_CourseOutlineResponse.swift @@ -19,6 +19,7 @@ public extension DataLayer { public let id: String public let media: DataLayer.CourseMedia public let certificate: Certificate? + public let isSelfPaced: Bool enum CodingKeys: String, CodingKey { case blocks @@ -26,14 +27,23 @@ public extension DataLayer { case id case media case certificate + case isSelfPaced = "is_self_paced" } - public init(rootItem: String, dict: Blocks, id: String, media: DataLayer.CourseMedia, certificate: Certificate?) { + public init( + rootItem: String, + dict: Blocks, + id: String, + media: DataLayer.CourseMedia, + certificate: Certificate?, + isSelfPaced: Bool + ) { self.rootItem = rootItem self.dict = dict self.id = id self.media = media self.certificate = certificate + self.isSelfPaced = isSelfPaced } public init(from decoder: Decoder) throws { @@ -44,6 +54,7 @@ public extension DataLayer { id = try values.decode(String.self, forKey: .id) media = try values.decode(DataLayer.CourseMedia.self, forKey: .media) certificate = try values.decode(Certificate.self, forKey: .certificate) + isSelfPaced = try values.decode(Bool.self, forKey: .isSelfPaced) } } } diff --git a/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents b/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents index 504919fac..cd0583f08 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 @@ - + @@ -79,6 +79,7 @@ + diff --git a/Course/Course/Domain/CourseInteractor.swift b/Course/Course/Domain/CourseInteractor.swift index cfd6a97fa..f30ed47b4 100644 --- a/Course/Course/Domain/CourseInteractor.swift +++ b/Course/Course/Domain/CourseInteractor.swift @@ -53,7 +53,8 @@ public class CourseInteractor: CourseInteractorProtocol { topicID: course.topicID, childs: newChilds, media: course.media, - certificate: course.certificate + certificate: course.certificate, + isSelfPaced: course.isSelfPaced ) } diff --git a/Course/Course/Presentation/CourseAnalytics.swift b/Course/Course/Presentation/CourseAnalytics.swift index b17cda57c..bc531c152 100644 --- a/Course/Course/Presentation/CourseAnalytics.swift +++ b/Course/Course/Presentation/CourseAnalytics.swift @@ -8,6 +8,44 @@ import Foundation import Core +public enum EnrollmentMode: String { + case audit + case verified + case none +} + +public enum CoursePacing: String { + case `self` = "self" + case instructor = "instructor" +} + +public enum CalendarDialogueAction: String { + case on = "on" + case off = "off" + case allow = "allow" + case doNotAllow = "donot_allow" + case add = "add" + case cancel = "cancel" + case remove = "remove" + case update = "update" + case done = "done" + case viewEvent = "view_event" +} + +public enum CalendarDialogueType: String { + case devicePermission = "device_permission" + case addCalendar = "add_calendar" + case removeCalendar = "remove_calendar" + case updateCalendar = "update_calendar" + case eventsAdded = "events_added" +} + +public enum SnackbarType: String { + case added + case removed + case updated +} + //sourcery: AutoMockable public protocol CourseAnalytics { func resumeCourseClicked(courseId: String, courseName: String, blockId: String) @@ -29,6 +67,25 @@ public protocol CourseAnalytics { link: String, supported: Bool ) + func calendarSyncToggle( + enrollmentMode: EnrollmentMode, + pacing: CoursePacing, + courseId: String, + action: CalendarDialogueAction + ) + func calendarSyncDialogAction( + enrollmentMode: EnrollmentMode, + pacing: CoursePacing, + courseId: String, + dialog: CalendarDialogueType, + action: CalendarDialogueAction + ) + func calendarSyncSnackbar( + enrollmentMode: EnrollmentMode, + pacing: CoursePacing, + courseId: String, + snackbar: SnackbarType + ) func trackCourseEvent(_ event: AnalyticsEvent, biValue: EventBIValue, courseID: String) func plsEvent( _ event: AnalyticsEvent, @@ -87,6 +144,25 @@ class CourseAnalyticsMock: CourseAnalytics { link: String, supported: Bool ) {} + func calendarSyncToggle( + enrollmentMode: EnrollmentMode, + pacing: CoursePacing, + courseId: String, + action: CalendarDialogueAction + ) {} + func calendarSyncDialogAction( + enrollmentMode: EnrollmentMode, + pacing: CoursePacing, + courseId: String, + dialog: CalendarDialogueType, + action: CalendarDialogueAction + ) {} + func calendarSyncSnackbar( + enrollmentMode: EnrollmentMode, + pacing: CoursePacing, + courseId: String, + snackbar: SnackbarType + ) {} public func trackCourseEvent(_ event: AnalyticsEvent, biValue: EventBIValue, courseID: String) {} public func plsEvent( _ event: AnalyticsEvent, diff --git a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift index f0cb1b8cd..b8c83aa0b 100644 --- a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift +++ b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift @@ -48,8 +48,10 @@ public class CourseDatesViewModel: ObservableObject { } set { if newValue { + trackCalendarSyncToggle(action: .on) handleCalendar() } else { + trackCalendarSyncToggle(action: .off) showRemoveCalendarAlert() } } @@ -217,8 +219,14 @@ extension CourseDatesViewModel { guard let self else { return } switch status { case .authorized: + if previousStatus == .notDetermined { + trackCalendarSyncDialogAction(dialog: .devicePermission, action: .allow) + } showAddCalendarAlert() default: + if previousStatus == .notDetermined { + trackCalendarSyncDialogAction(dialog: .devicePermission, action: .doNotAllow) + } isOn = false if previousStatus == status { self.showCalendarSettingsAlert() @@ -260,11 +268,13 @@ extension CourseDatesViewModel { ), positiveAction: CoreLocalization.Alert.accept, onCloseTapped: { [weak self] in + self?.trackCalendarSyncDialogAction(dialog: .addCalendar, action: .cancel) self?.router.dismiss(animated: true) self?.isOn = false self?.calendar.syncOn = false }, okTapped: { [weak self] in + self?.trackCalendarSyncDialogAction(dialog: .addCalendar, action: .add) self?.router.dismiss(animated: true) Task { [weak self] in await self?.addCourseEvents() @@ -283,11 +293,14 @@ extension CourseDatesViewModel { ), positiveAction: CoreLocalization.Alert.accept, onCloseTapped: { [weak self] in + self?.trackCalendarSyncDialogAction(dialog: .removeCalendar, action: .cancel) self?.router.dismiss(animated: true) }, okTapped: { [weak self] in + self?.trackCalendarSyncDialogAction(dialog: .removeCalendar, action: .remove) self?.router.dismiss(animated: true) self?.removeCourseCalendar { [weak self] _ in + self?.trackCalendarSyncSnackbar(snackbar: .removed) self?.eventState = .removedCalendar } @@ -298,6 +311,7 @@ extension CourseDatesViewModel { private func showEventsAddedSuccessAlert() { if calendar.isModalPresented { + trackCalendarSyncSnackbar(snackbar: .added) eventState = .addedCalendar return } @@ -309,11 +323,13 @@ extension CourseDatesViewModel { ), positiveAction: CourseLocalization.CourseDates.calendarViewEvents, onCloseTapped: { [weak self] in + self?.trackCalendarSyncDialogAction(dialog: .eventsAdded, action: .done) self?.router.dismiss(animated: true) self?.isOn = true self?.calendar.syncOn = true }, okTapped: { [weak self] in + self?.trackCalendarSyncDialogAction(dialog: .eventsAdded, action: .viewEvent) self?.router.dismiss(animated: true) if let url = URL(string: "calshow://"), UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url, options: [:], completionHandler: nil) @@ -350,13 +366,16 @@ extension CourseDatesViewModel { positiveAction: CourseLocalization.CourseDates.calendarShiftPromptUpdateNow, onCloseTapped: { [weak self] in // Remove course calendar + self?.trackCalendarSyncDialogAction(dialog: .updateCalendar, action: .remove) self?.router.dismiss(animated: true) self?.removeCourseCalendar { [weak self] _ in + self?.trackCalendarSyncSnackbar(snackbar: .removed) self?.eventState = .removedCalendar } }, okTapped: { [weak self] in // Update Calendar Now + self?.trackCalendarSyncDialogAction(dialog: .updateCalendar, action: .update) self?.router.dismiss(animated: true) self?.removeCourseCalendar(trackAnalytics: false) { success in self?.isOn = !success @@ -364,6 +383,7 @@ extension CourseDatesViewModel { self?.addCourseEvents(trackAnalytics: false) { [weak self] calendarEventsAdded in self?.isOn = calendarEventsAdded if calendarEventsAdded { + self?.trackCalendarSyncSnackbar(snackbar: .updated) self?.calendar.syncOn = calendarEventsAdded self?.eventState = .updatedCalendar } @@ -444,3 +464,33 @@ extension CourseDatesViewModel { ) } } + +extension CourseDatesViewModel { + private func trackCalendarSyncToggle(action: CalendarDialogueAction) { + analytics.calendarSyncToggle( + enrollmentMode: .none, + pacing: courseStructure?.isSelfPaced ?? true ? .`self` : .instructor, + courseId: courseID, + action: action + ) + } + + private func trackCalendarSyncDialogAction(dialog: CalendarDialogueType, action: CalendarDialogueAction) { + analytics.calendarSyncDialogAction( + enrollmentMode: .none, + pacing: courseStructure?.isSelfPaced ?? true ? .`self` : .instructor, + courseId: courseID, + dialog: dialog, + action: action + ) + } + + private func trackCalendarSyncSnackbar(snackbar: SnackbarType) { + analytics.calendarSyncSnackbar( + enrollmentMode: .none, + pacing: courseStructure?.isSelfPaced ?? true ? .`self` : .instructor, + courseId: courseID, + snackbar: snackbar + ) + } +} diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index 080b636d1..1b68b62f7 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -1518,6 +1518,24 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { perform?(`courseId`, `blockId`, `link`, `supported`) } + open func calendarSyncToggle(enrollmentMode: EnrollmentMode, pacing: CoursePacing, courseId: String, action: CalendarDialogueAction) { + addInvocation(.m_calendarSyncToggle__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIdaction_action(Parameter.value(`enrollmentMode`), Parameter.value(`pacing`), Parameter.value(`courseId`), Parameter.value(`action`))) + let perform = methodPerformValue(.m_calendarSyncToggle__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIdaction_action(Parameter.value(`enrollmentMode`), Parameter.value(`pacing`), Parameter.value(`courseId`), Parameter.value(`action`))) as? (EnrollmentMode, CoursePacing, String, CalendarDialogueAction) -> Void + perform?(`enrollmentMode`, `pacing`, `courseId`, `action`) + } + + open func calendarSyncDialogAction(enrollmentMode: EnrollmentMode, pacing: CoursePacing, courseId: String, dialog: CalendarDialogueType, action: CalendarDialogueAction) { + addInvocation(.m_calendarSyncDialogAction__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIddialog_dialogaction_action(Parameter.value(`enrollmentMode`), Parameter.value(`pacing`), Parameter.value(`courseId`), Parameter.value(`dialog`), Parameter.value(`action`))) + let perform = methodPerformValue(.m_calendarSyncDialogAction__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIddialog_dialogaction_action(Parameter.value(`enrollmentMode`), Parameter.value(`pacing`), Parameter.value(`courseId`), Parameter.value(`dialog`), Parameter.value(`action`))) as? (EnrollmentMode, CoursePacing, String, CalendarDialogueType, CalendarDialogueAction) -> Void + perform?(`enrollmentMode`, `pacing`, `courseId`, `dialog`, `action`) + } + + open func calendarSyncSnackbar(enrollmentMode: EnrollmentMode, pacing: CoursePacing, courseId: String, snackbar: SnackbarType) { + addInvocation(.m_calendarSyncSnackbar__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIdsnackbar_snackbar(Parameter.value(`enrollmentMode`), Parameter.value(`pacing`), Parameter.value(`courseId`), Parameter.value(`snackbar`))) + let perform = methodPerformValue(.m_calendarSyncSnackbar__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIdsnackbar_snackbar(Parameter.value(`enrollmentMode`), Parameter.value(`pacing`), Parameter.value(`courseId`), Parameter.value(`snackbar`))) as? (EnrollmentMode, CoursePacing, String, SnackbarType) -> Void + perform?(`enrollmentMode`, `pacing`, `courseId`, `snackbar`) + } + open func trackCourseEvent(_ event: AnalyticsEvent, biValue: EventBIValue, courseID: String) { addInvocation(.m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`courseID`))) let perform = methodPerformValue(.m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`courseID`))) as? (AnalyticsEvent, EventBIValue, String) -> Void @@ -1570,6 +1588,9 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { case m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported(Parameter, Parameter, Parameter, Parameter) + case m_calendarSyncToggle__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIdaction_action(Parameter, Parameter, Parameter, Parameter) + case m_calendarSyncDialogAction__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIddialog_dialogaction_action(Parameter, Parameter, Parameter, Parameter, Parameter) + case m_calendarSyncSnackbar__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIdsnackbar_snackbar(Parameter, Parameter, Parameter, Parameter) case m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(Parameter, Parameter, Parameter) case m_plsEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_type(Parameter, Parameter, Parameter, Parameter, Parameter) case m_plsSuccessEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_typesuccess_success(Parameter, Parameter, Parameter, Parameter, Parameter, Parameter) @@ -1678,6 +1699,31 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSupported, rhs: rhsSupported, with: matcher), lhsSupported, rhsSupported, "supported")) return Matcher.ComparisonResult(results) + case (.m_calendarSyncToggle__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIdaction_action(let lhsEnrollmentmode, let lhsPacing, let lhsCourseid, let lhsAction), .m_calendarSyncToggle__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIdaction_action(let rhsEnrollmentmode, let rhsPacing, let rhsCourseid, let rhsAction)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEnrollmentmode, rhs: rhsEnrollmentmode, with: matcher), lhsEnrollmentmode, rhsEnrollmentmode, "enrollmentMode")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsPacing, rhs: rhsPacing, with: matcher), lhsPacing, rhsPacing, "pacing")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + return Matcher.ComparisonResult(results) + + case (.m_calendarSyncDialogAction__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIddialog_dialogaction_action(let lhsEnrollmentmode, let lhsPacing, let lhsCourseid, let lhsDialog, let lhsAction), .m_calendarSyncDialogAction__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIddialog_dialogaction_action(let rhsEnrollmentmode, let rhsPacing, let rhsCourseid, let rhsDialog, let rhsAction)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEnrollmentmode, rhs: rhsEnrollmentmode, with: matcher), lhsEnrollmentmode, rhsEnrollmentmode, "enrollmentMode")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsPacing, rhs: rhsPacing, with: matcher), lhsPacing, rhsPacing, "pacing")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDialog, rhs: rhsDialog, with: matcher), lhsDialog, rhsDialog, "dialog")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + return Matcher.ComparisonResult(results) + + case (.m_calendarSyncSnackbar__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIdsnackbar_snackbar(let lhsEnrollmentmode, let lhsPacing, let lhsCourseid, let lhsSnackbar), .m_calendarSyncSnackbar__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIdsnackbar_snackbar(let rhsEnrollmentmode, let rhsPacing, let rhsCourseid, let rhsSnackbar)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEnrollmentmode, rhs: rhsEnrollmentmode, with: matcher), lhsEnrollmentmode, rhsEnrollmentmode, "enrollmentMode")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsPacing, rhs: rhsPacing, with: matcher), lhsPacing, rhsPacing, "pacing")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSnackbar, rhs: rhsSnackbar, with: matcher), lhsSnackbar, rhsSnackbar, "snackbar")) + return Matcher.ComparisonResult(results) + case (.m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(let lhsEvent, let lhsBivalue, let lhsCourseid), .m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(let rhsEvent, let rhsBivalue, let rhsCourseid)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) @@ -1744,6 +1790,9 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { 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 case let .m_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_calendarSyncToggle__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIdaction_action(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + case let .m_calendarSyncDialogAction__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIddialog_dialogaction_action(p0, p1, p2, p3, p4): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + case let .m_calendarSyncSnackbar__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIdsnackbar_snackbar(p0, p1, p2, p3): return p0.intValue + p1.intValue + p2.intValue + p3.intValue case let .m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case let .m_plsEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_type(p0, p1, p2, p3, p4): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue case let .m_plsSuccessEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_typesuccess_success(p0, p1, p2, p3, p4, p5): return p0.intValue + p1.intValue + p2.intValue + p3.intValue + p4.intValue + p5.intValue @@ -1768,6 +1817,9 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { case .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineDiscussionTabClicked(courseId:courseName:)" case .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineHandoutsTabClicked(courseId:courseName:)" case .m_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported: return ".datesComponentTapped(courseId:blockId:link:supported:)" + case .m_calendarSyncToggle__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIdaction_action: return ".calendarSyncToggle(enrollmentMode:pacing:courseId:action:)" + case .m_calendarSyncDialogAction__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIddialog_dialogaction_action: return ".calendarSyncDialogAction(enrollmentMode:pacing:courseId:dialog:action:)" + case .m_calendarSyncSnackbar__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIdsnackbar_snackbar: return ".calendarSyncSnackbar(enrollmentMode:pacing:courseId:snackbar:)" case .m_trackCourseEvent__eventbiValue_biValuecourseID_courseID: return ".trackCourseEvent(_:biValue:courseID:)" case .m_plsEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_type: return ".plsEvent(_:bivalue:courseID:screenName:type:)" case .m_plsSuccessEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_typesuccess_success: return ".plsSuccessEvent(_:bivalue:courseID:screenName:type:success:)" @@ -1806,6 +1858,9 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { public static func courseOutlineDiscussionTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseOutlineHandoutsTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func datesComponentTapped(courseId: Parameter, blockId: Parameter, link: Parameter, supported: Parameter) -> Verify { return Verify(method: .m_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported(`courseId`, `blockId`, `link`, `supported`))} + public static func calendarSyncToggle(enrollmentMode: Parameter, pacing: Parameter, courseId: Parameter, action: Parameter) -> Verify { return Verify(method: .m_calendarSyncToggle__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIdaction_action(`enrollmentMode`, `pacing`, `courseId`, `action`))} + public static func calendarSyncDialogAction(enrollmentMode: Parameter, pacing: Parameter, courseId: Parameter, dialog: Parameter, action: Parameter) -> Verify { return Verify(method: .m_calendarSyncDialogAction__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIddialog_dialogaction_action(`enrollmentMode`, `pacing`, `courseId`, `dialog`, `action`))} + public static func calendarSyncSnackbar(enrollmentMode: Parameter, pacing: Parameter, courseId: Parameter, snackbar: Parameter) -> Verify { return Verify(method: .m_calendarSyncSnackbar__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIdsnackbar_snackbar(`enrollmentMode`, `pacing`, `courseId`, `snackbar`))} public static func trackCourseEvent(_ event: Parameter, biValue: Parameter, courseID: Parameter) -> Verify { return Verify(method: .m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(`event`, `biValue`, `courseID`))} public static func plsEvent(_ event: Parameter, bivalue: Parameter, courseID: Parameter, screenName: Parameter, type: Parameter) -> Verify { return Verify(method: .m_plsEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_type(`event`, `bivalue`, `courseID`, `screenName`, `type`))} public static func plsSuccessEvent(_ event: Parameter, bivalue: Parameter, courseID: Parameter, screenName: Parameter, type: Parameter, success: Parameter) -> Verify { return Verify(method: .m_plsSuccessEvent__eventbivalue_bivaluecourseID_courseIDscreenName_screenNametype_typesuccess_success(`event`, `bivalue`, `courseID`, `screenName`, `type`, `success`))} @@ -1860,6 +1915,15 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { public static func datesComponentTapped(courseId: Parameter, blockId: Parameter, link: Parameter, supported: Parameter, perform: @escaping (String, String, String, Bool) -> Void) -> Perform { return Perform(method: .m_datesComponentTapped__courseId_courseIdblockId_blockIdlink_linksupported_supported(`courseId`, `blockId`, `link`, `supported`), performs: perform) } + public static func calendarSyncToggle(enrollmentMode: Parameter, pacing: Parameter, courseId: Parameter, action: Parameter, perform: @escaping (EnrollmentMode, CoursePacing, String, CalendarDialogueAction) -> Void) -> Perform { + return Perform(method: .m_calendarSyncToggle__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIdaction_action(`enrollmentMode`, `pacing`, `courseId`, `action`), performs: perform) + } + public static func calendarSyncDialogAction(enrollmentMode: Parameter, pacing: Parameter, courseId: Parameter, dialog: Parameter, action: Parameter, perform: @escaping (EnrollmentMode, CoursePacing, String, CalendarDialogueType, CalendarDialogueAction) -> Void) -> Perform { + return Perform(method: .m_calendarSyncDialogAction__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIddialog_dialogaction_action(`enrollmentMode`, `pacing`, `courseId`, `dialog`, `action`), performs: perform) + } + public static func calendarSyncSnackbar(enrollmentMode: Parameter, pacing: Parameter, courseId: Parameter, snackbar: Parameter, perform: @escaping (EnrollmentMode, CoursePacing, String, SnackbarType) -> Void) -> Perform { + return Perform(method: .m_calendarSyncSnackbar__enrollmentMode_enrollmentModepacing_pacingcourseId_courseIdsnackbar_snackbar(`enrollmentMode`, `pacing`, `courseId`, `snackbar`), performs: perform) + } public static func trackCourseEvent(_ event: Parameter, biValue: Parameter, courseID: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String) -> Void) -> Perform { return Perform(method: .m_trackCourseEvent__eventbiValue_biValuecourseID_courseID(`event`, `biValue`, `courseID`), performs: perform) } diff --git a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift index d86174a66..788115993 100644 --- a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift @@ -96,7 +96,8 @@ final class CourseContainerViewModelTests: XCTestCase { media: DataLayer.CourseMedia(image: DataLayer.Image(raw: "", small: "", large: "")), - certificate: nil + certificate: nil, + isSelfPaced: true ) let resumeBlock = ResumeBlock(blockID: "123") @@ -161,7 +162,8 @@ final class CourseContainerViewModelTests: XCTestCase { media: DataLayer.CourseMedia(image: DataLayer.Image(raw: "", small: "", large: "")), - certificate: nil + certificate: nil, + isSelfPaced: true ) Given(interactor, .getLoadedCourseBlocks(courseID: .any, willReturn: courseStructure)) @@ -417,7 +419,8 @@ final class CourseContainerViewModelTests: XCTestCase { small: "", large: "" )), - certificate: nil + certificate: nil, + isSelfPaced: true ) let downloadData = DownloadDataTask( @@ -551,7 +554,8 @@ final class CourseContainerViewModelTests: XCTestCase { small: "", large: "" )), - certificate: nil + certificate: nil, + isSelfPaced: true ) Given(connectivity, .isInternetAvaliable(getter: true)) @@ -670,7 +674,8 @@ final class CourseContainerViewModelTests: XCTestCase { small: "", large: "" )), - certificate: nil + certificate: nil, + isSelfPaced: true ) Given(connectivity, .isInternetAvaliable(getter: true)) @@ -790,7 +795,8 @@ final class CourseContainerViewModelTests: XCTestCase { small: "", large: "" )), - certificate: nil + certificate: nil, + isSelfPaced: true ) Given(connectivity, .isInternetAvaliable(getter: true)) @@ -903,7 +909,8 @@ final class CourseContainerViewModelTests: XCTestCase { small: "", large: "" )), - certificate: nil + certificate: nil, + isSelfPaced: true ) let downloadData = DownloadDataTask( @@ -1031,7 +1038,8 @@ final class CourseContainerViewModelTests: XCTestCase { small: "", large: "" )), - certificate: nil + certificate: nil, + isSelfPaced: true ) let downloadData = DownloadDataTask( @@ -1179,7 +1187,8 @@ final class CourseContainerViewModelTests: XCTestCase { small: "", large: "" )), - certificate: nil + certificate: nil, + isSelfPaced: true ) let downloadData = DownloadDataTask( diff --git a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift index 5e10ac799..1e8c4b92f 100644 --- a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift @@ -44,7 +44,8 @@ final class CourseDateViewModelTests: XCTestCase { media: DataLayer.CourseMedia(image: DataLayer.Image(raw: "", small: "", large: "")), - certificate: nil + certificate: nil, + isSelfPaced: true ) Given(interactor, .getCourseDates(courseID: .any, willReturn: courseDates)) diff --git a/OpenEdX/Data/CoursePersistence.swift b/OpenEdX/Data/CoursePersistence.swift index ff6eea345..98687f094 100644 --- a/OpenEdX/Data/CoursePersistence.swift +++ b/OpenEdX/Data/CoursePersistence.swift @@ -138,7 +138,8 @@ public class CoursePersistence: CoursePersistenceProtocol { large: structure.mediaLarge ?? "" ) ), - certificate: DataLayer.Certificate(url: structure.certificate) + certificate: DataLayer.Certificate(url: structure.certificate), + isSelfPaced: structure.isSelfPaced ) } @@ -151,6 +152,7 @@ public class CoursePersistence: CoursePersistenceProtocol { newStructure.mediaRaw = structure.media.image.raw newStructure.id = structure.id newStructure.rootItem = structure.rootItem + newStructure.isSelfPaced = structure.isSelfPaced for block in Array(structure.dict.values) { let courseDetail = CDCourseBlock(context: self.context) diff --git a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift index 116acfef8..aca6aff6b 100644 --- a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift +++ b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift @@ -420,8 +420,8 @@ class AnalyticsManager: AuthorizationAnalytics, let parameters = [ EventParamKey.courseID: courseId, EventParamKey.courseName: courseName, - EventParamKey.unitID: blockId, - EventParamKey.unitName: blockName + EventParamKey.blockID: blockId, + EventParamKey.blockName: blockName ] logEvent(.verticalClicked, parameters: parameters) } @@ -547,6 +547,62 @@ class AnalyticsManager: AuthorizationAnalytics, logEvent(.datesComponentClicked, parameters: parameters) } + func calendarSyncToggle( + enrollmentMode: EnrollmentMode, + pacing: CoursePacing, + courseId: String, + action: CalendarDialogueAction + ) { + let parameters: [String: Any] = [ + EventParamKey.enrollmentMode: enrollmentMode.rawValue, + EventParamKey.pacing: pacing.rawValue, + EventParamKey.courseID: courseId, + EventParamKey.action: action.rawValue, + EventParamKey.category: EventCategory.courseDates, + EventParamKey.name: EventBIValue.datesCalendarSyncToggle.rawValue + ] + + logEvent(.datesCalendarSyncToggle, parameters: parameters) + } + + func calendarSyncDialogAction( + enrollmentMode: EnrollmentMode, + pacing: CoursePacing, + courseId: String, + dialog: CalendarDialogueType, + action: CalendarDialogueAction + ) { + let parameters: [String: Any] = [ + EventParamKey.enrollmentMode: enrollmentMode.rawValue, + EventParamKey.pacing: pacing.rawValue, + EventParamKey.courseID: courseId, + EventParamKey.dialog: dialog.rawValue, + EventParamKey.action: action.rawValue, + EventParamKey.category: EventCategory.courseDates, + EventParamKey.name: EventBIValue.datesCalendarSyncDialogAction.rawValue + ] + + logEvent(.datesCalendarSyncDialogAction, parameters: parameters) + } + + func calendarSyncSnackbar( + enrollmentMode: EnrollmentMode, + pacing: CoursePacing, + courseId: String, + snackbar: SnackbarType + ) { + let parameters: [String: Any] = [ + EventParamKey.enrollmentMode: enrollmentMode.rawValue, + EventParamKey.pacing: pacing.rawValue, + EventParamKey.courseID: courseId, + EventParamKey.snackbar: snackbar.rawValue, + EventParamKey.category: EventCategory.courseDates, + EventParamKey.name: EventBIValue.datesCalendarSyncSnackbar.rawValue + ] + + logEvent(.datesCalendarSyncSnackbar, parameters: parameters) + } + public func trackCourseEvent(_ event: AnalyticsEvent, biValue: EventBIValue, courseID: String) { let parameters = [ EventParamKey.courseID: courseID, @@ -678,7 +734,7 @@ class AnalyticsManager: AuthorizationAnalytics, ) { var parameters: [String: Any] = [ EventParamKey.category: EventCategory.appreviews, - EventParamKey.name: biValue.rawValue, + EventParamKey.name: biValue.rawValue ] if rating != 0 { From 581a722c646411d9275d668f55b54c984f4362c4 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Mon, 15 Apr 2024 14:45:32 +0200 Subject: [PATCH 132/136] chore: updated youtube player kit varsion --- Core/Core.xcodeproj/project.pbxproj | 2 +- Course/Course/Presentation/Video/YouTubeVideoPlayer.swift | 5 ++++- .../Presentation/Video/YouTubeVideoPlayerViewModel.swift | 8 ++++---- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index d17b77529..d0543bc53 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -2217,7 +2217,7 @@ repositoryURL = "https://github.com/SvenTiigi/YouTubePlayerKit"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.5.0; + minimumVersion = 1.8.0; }; }; 142EDD6A2B831D1400F9F320 /* XCRemoteSwiftPackageReference "ios-branch-sdk-spm" */ = { diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift index 82955ee63..776e62937 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift @@ -60,7 +60,10 @@ public struct YouTubeVideoPlayer: View { languages: viewModel.languages, currentTime: $viewModel.currentTime, viewModel: viewModel, scrollTo: { date in - viewModel.youtubePlayer.seek(to: date.secondsSinceMidnight(), allowSeekAhead: true) + viewModel.youtubePlayer.seek( + to: Measurement(value: date.secondsSinceMidnight(), unit: UnitDuration.seconds), + allowSeekAhead: true + ) viewModel.youtubePlayer.play() viewModel.pauseScrolling() viewModel.currentTime = date.secondsSinceMidnight() + 1 diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift index 92bdad530..077a1e0e3 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift @@ -91,17 +91,17 @@ public class YouTubeVideoPlayerViewModel: VideoPlayerViewModel { }).store(in: &subscription) youtubePlayer.durationPublisher.sink(receiveValue: { [weak self] duration in - self?.duration = duration + self?.duration = duration.value }).store(in: &subscription) youtubePlayer.currentTimePublisher(updateInterval: 0.1).sink(receiveValue: { [weak self] time in guard let self else { return } if !self.pause { - self.currentTime = time + self.currentTime = time.value } if let duration = self.duration { - if (time / duration) >= 0.8 { + if (time.value / duration) >= 0.8 { if !isViewedOnce { Task { await self.blockCompletionRequest() @@ -110,7 +110,7 @@ public class YouTubeVideoPlayerViewModel: VideoPlayerViewModel { isViewedOnce = true } } - if (time / duration) >= 0.999 { + if (time.value / duration) >= 0.999 { self.router.presentAppReview() } } From 2711db6a8ca603ecab092a71c79b0e2d4e7feb6b Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Mon, 15 Apr 2024 16:29:48 +0200 Subject: [PATCH 133/136] chore: code style --- Course/Course/Presentation/Video/YouTubeVideoPlayer.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift index 776e62937..631e11a7c 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift @@ -59,7 +59,8 @@ public struct YouTubeVideoPlayer: View { SubtittlesView( languages: viewModel.languages, currentTime: $viewModel.currentTime, - viewModel: viewModel, scrollTo: { date in + viewModel: viewModel, + scrollTo: { date in viewModel.youtubePlayer.seek( to: Measurement(value: date.secondsSinceMidnight(), unit: UnitDuration.seconds), allowSeekAhead: true From 574818147e407a04aecb8f4cb8281e939d63eb10 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Fri, 19 Apr 2024 10:24:38 +0200 Subject: [PATCH 134/136] fix for discussion pull to refresh (#393) * chore: get data from API when refresh * chore: refactor; added refresh flag * chore: code style * fix: fixed tests --------- Co-authored-by: Vadim Kuznetsov --- Discussion/Discussion/Domain/Model/Post.swift | 26 +++++++++++++---- .../Discussion/Domain/Model/UserComment.swift | 21 +++++++++++--- .../Discussion/Domain/Model/UserThread.swift | 29 +++++++++++++++---- .../Comments/Responses/ResponsesView.swift | 7 +++-- .../Responses/ResponsesViewModel.swift | 28 ++++++++++++++---- .../Comments/Thread/ThreadView.swift | 11 ++----- .../Comments/Thread/ThreadViewModel.swift | 26 ++++++++++++++--- .../Comment/ThreadViewModelTests.swift | 8 ++--- .../Responses/ResponsesViewModelTests.swift | 6 ++-- 9 files changed, 119 insertions(+), 43 deletions(-) diff --git a/Discussion/Discussion/Domain/Model/Post.swift b/Discussion/Discussion/Domain/Model/Post.swift index 0e2f09047..ec034e61f 100644 --- a/Discussion/Discussion/Domain/Model/Post.swift +++ b/Discussion/Discussion/Domain/Model/Post.swift @@ -24,13 +24,27 @@ public struct Post { public let commentID: String public let parentID: String? public var abuseFlagged: Bool - public let closed: Bool + public var closed: Bool - public init(authorName: String, authorAvatar: String, postDate: Date, postTitle: String, postBodyHtml: String, - postBody: String, - postVisible: Bool, voted: Bool, followed: Bool, votesCount: Int, responsesCount: Int, - comments: [Post], threadID: String, commentID: String, parentID: String?, abuseFlagged: Bool, - closed: Bool) { + public init( + authorName: String, + authorAvatar: String, + postDate: Date, + postTitle: String, + postBodyHtml: String, + postBody: String, + postVisible: Bool, + voted: Bool, + followed: Bool, + votesCount: Int, + responsesCount: Int, + comments: [Post], + threadID: String, + commentID: String, + parentID: String?, + abuseFlagged: Bool, + closed: Bool + ) { self.authorName = authorName self.authorAvatar = authorAvatar self.postDate = postDate diff --git a/Discussion/Discussion/Domain/Model/UserComment.swift b/Discussion/Discussion/Domain/Model/UserComment.swift index cdc8ecb92..4f3ef35af 100644 --- a/Discussion/Discussion/Domain/Model/UserComment.swift +++ b/Discussion/Discussion/Domain/Model/UserComment.swift @@ -24,10 +24,23 @@ public struct UserComment: Hashable { public let parentID: String? public var abuseFlagged: Bool - public init(authorName: String, authorAvatar: String, postDate: Date, postTitle: String, postBody: String, - postBodyHtml: String, - postVisible: Bool, voted: Bool, followed: Bool, votesCount: Int, responsesCount: Int, - threadID: String, commentID: String, parentID: String?, abuseFlagged: Bool) { + public init( + authorName: String, + authorAvatar: String, + postDate: Date, + postTitle: String, + postBody: String, + postBodyHtml: String, + postVisible: Bool, + voted: Bool, + followed: Bool, + votesCount: Int, + responsesCount: Int, + threadID: String, + commentID: String, + parentID: String?, + abuseFlagged: Bool + ) { self.authorName = authorName self.authorAvatar = authorAvatar self.postDate = postDate diff --git a/Discussion/Discussion/Domain/Model/UserThread.swift b/Discussion/Discussion/Domain/Model/UserThread.swift index b87a14a36..81e8dcf0c 100644 --- a/Discussion/Discussion/Domain/Model/UserThread.swift +++ b/Discussion/Discussion/Domain/Model/UserThread.swift @@ -33,16 +33,35 @@ public struct UserThread { public let closed: Bool public var following: Bool public var commentCount: Int - public let avatar: String + public var avatar: String public var unreadCommentCount: Int public var abuseFlagged: Bool public let hasEndorsed: Bool public let numPages: Int - public init(id: String, author: String, authorLabel: String, createdAt: Date, updatedAt: Date, rawBody: String, - renderedBody: String, voted: Bool, voteCount: Int, courseID: String, type: PostType, title: String, - pinned: Bool, closed: Bool, following: Bool, commentCount: Int, avatar: String, unreadCommentCount: Int, - abuseFlagged: Bool, hasEndorsed: Bool, numPages: Int) { + public init( + id: String, + author: String, + authorLabel: String, + createdAt: Date, + updatedAt: Date, + rawBody: String, + renderedBody: String, + voted: Bool, + voteCount: Int, + courseID: String, + type: PostType, + title: String, + pinned: Bool, + closed: Bool, + following: Bool, + commentCount: Int, + avatar: String, + unreadCommentCount: Int, + abuseFlagged: Bool, + hasEndorsed: Bool, + numPages: Int + ) { self.id = id self.author = author self.authorLabel = authorLabel diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index a9b8ef2cb..d4dab6464 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -33,7 +33,7 @@ public struct ResponsesView: View { self.viewModel = viewModel self.router = router Task { - await viewModel.getComments(commentID: commentID, parentComment: parentComment, page: 1) + await viewModel.getResponsesData(commentID: commentID, parentComment: parentComment, page: 1) } viewModel.addCommentsIsVisible = false self.viewModel.isBlackedOut = isBlackedOut @@ -48,10 +48,11 @@ public struct ResponsesView: View { ZStack(alignment: .top) { RefreshableScrollViewCompat(action: { viewModel.comments = [] - _ = await viewModel.getComments( + _ = await viewModel.getResponsesData( commentID: commentID, parentComment: parentComment, - page: 1 + page: 1, + refresh: true ) }) { VStack { diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift index 91ac5d515..c8992fd8a 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift @@ -76,9 +76,11 @@ public class ResponsesViewModel: BaseResponsesViewModel, ObservableObject { if index == comments.count - 3 { if totalPages != 1 { if nextPage != totalPages + 1 { - if await self.getComments(commentID: commentID, - parentComment: parentComment, - page: nextPage) { + if await self.getResponsesData( + commentID: commentID, + parentComment: parentComment, + page: nextPage + ) { self.nextPage += 1 } } @@ -88,19 +90,23 @@ public class ResponsesViewModel: BaseResponsesViewModel, ObservableObject { } @MainActor - func getComments(commentID: String, parentComment: Post, page: Int) async -> Bool { + func getResponsesData(commentID: String, parentComment: Post, page: Int, refresh: Bool = false) async -> Bool { guard !fetchInProgress else { return false } do { let (comments, pagination) = try await interactor .getCommentResponses(commentID: commentID, page: page) self.totalPages = pagination.numPages self.itemsCount = pagination.count + var parentPost = parentComment if page == 1 { self.comments = comments + if refresh { + parentPost = await getParentPost(parentComment: parentComment) + } } else { self.comments += comments } - postComments = generateCommentsResponses(comments: self.comments, parentComment: parentComment) + postComments = generateCommentsResponses(comments: self.comments, parentComment: parentPost) return true } catch let error { if error.isInternetError { @@ -112,6 +118,18 @@ public class ResponsesViewModel: BaseResponsesViewModel, ObservableObject { } } + func getParentPost(parentComment: Post) async -> Post { + do { + let parentCommentData = try await interactor.getResponse(responseID: parentComment.commentID) + var parentPost = parentCommentData.post + parentPost.closed = parentComment.closed + parentPost.authorAvatar = parentComment.authorAvatar + return parentPost + } catch { + return parentComment + } + } + func sendThreadLikeState() { if let postComments { threadStateSubject.send(.voted( diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index 2c7632730..84f2b6816 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -37,7 +37,7 @@ public struct ThreadView: View { VStack { ZStack(alignment: .top) { RefreshableScrollViewCompat(action: { - _ = await viewModel.getPosts(thread: thread, page: 1) + _ = await viewModel.getThreadData(thread: thread, page: 1, refresh: true) }) { VStack { if let comments = viewModel.postComments { @@ -241,7 +241,7 @@ public struct ThreadView: View { } .onFirstAppear { Task { - await viewModel.getPosts(thread: thread, page: 1) + await viewModel.getThreadData(thread: thread, page: 1) } } .onDisappear { @@ -255,13 +255,6 @@ public struct ThreadView: View { ) } } - - private func reloadPage(onSuccess: @escaping () -> Void) { - Task { - if await viewModel.getPosts(thread: thread, - page: viewModel.nextPage-1) { onSuccess() } - } - } } #if DEBUG diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift index 247a097d0..2fb75b60f 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift @@ -113,7 +113,7 @@ public class ThreadViewModel: BaseResponsesViewModel, ObservableObject { if index == comments.count - 3 { if totalPages != 1 { if nextPage != totalPages+1 { - if await self.getPosts(thread: thread, page: nextPage) { + if await self.getThreadData(thread: thread, page: nextPage) { self.nextPage += 1 return true } @@ -125,7 +125,7 @@ public class ThreadViewModel: BaseResponsesViewModel, ObservableObject { } @MainActor - public func getPosts(thread: UserThread, page: Int) async -> Bool { + public func getThreadData(thread: UserThread, page: Int, refresh: Bool = false) async -> Bool { guard !fetchInProgress else { return false } fetchInProgress = true do { @@ -136,23 +136,31 @@ public class ThreadViewModel: BaseResponsesViewModel, ObservableObject { .getQuestionComments(threadID: thread.id, page: page) self.totalPages = pagination.numPages self.itemsCount = pagination.count + var threadPost = thread if page == 1 { self.comments = comments + if refresh { + threadPost = await getThreadPost(thread: thread) + } } else { self.comments += comments } - postComments = generateComments(comments: self.comments, thread: thread) + postComments = generateComments(comments: self.comments, thread: threadPost) case .discussion: let (comments, pagination) = try await interactor .getDiscussionComments(threadID: thread.id, page: page) self.totalPages = pagination.numPages self.itemsCount = pagination.count + var threadPost = thread if page == 1 { self.comments = comments + if refresh { + threadPost = await getThreadPost(thread: thread) + } } else { self.comments += comments } - postComments = generateComments(comments: self.comments, thread: thread) + postComments = generateComments(comments: self.comments, thread: threadPost) } fetchInProgress = false return true @@ -167,6 +175,16 @@ public class ThreadViewModel: BaseResponsesViewModel, ObservableObject { } } + func getThreadPost(thread: UserThread) async -> UserThread { + do { + var threadPost = try await interactor.getThread(threadID: thread.id) + threadPost.avatar = thread.avatar + return threadPost + } catch { + return thread + } + } + func sendPostFollowedState() { if let postComments { postStateSubject.send(.followed(id: postComments.threadID, postComments.followed)) diff --git a/Discussion/DiscussionTests/Presentation/Comment/ThreadViewModelTests.swift b/Discussion/DiscussionTests/Presentation/Comment/ThreadViewModelTests.swift index 724e99e32..7da4cdf55 100644 --- a/Discussion/DiscussionTests/Presentation/Comment/ThreadViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/Comment/ThreadViewModelTests.swift @@ -221,7 +221,7 @@ final class ThreadViewModelTests: XCTestCase { count: 1, numPages: 1)))) - result = await viewModel.getPosts(thread: threads.threads[0], page: 1) + result = await viewModel.getThreadData(thread: threads.threads[0], page: 1) Verify(interactor, .readBody(threadID: .value(threads.threads[0].id))) Verify(interactor, .getQuestionComments(threadID: .value(threads.threads[0].id), page: .value(1))) @@ -250,7 +250,7 @@ final class ThreadViewModelTests: XCTestCase { count: 1, numPages: 1)))) - result = await viewModel.getPosts(thread: threads.threads[1], page: 1) + result = await viewModel.getThreadData(thread: threads.threads[1], page: 1) Verify(interactor, .readBody(threadID: .value(threads.threads[1].id))) Verify(interactor, .getDiscussionComments(threadID: .value(threads.threads[1].id), page: .value(1))) @@ -277,7 +277,7 @@ final class ThreadViewModelTests: XCTestCase { Given(interactor, .readBody(threadID: .any, willThrow: noInternetError)) Given(interactor, .getQuestionComments(threadID: .any, page: .any, willThrow: noInternetError)) - result = await viewModel.getPosts(thread: threads.threads[0], page: 1) + result = await viewModel.getThreadData(thread: threads.threads[0], page: 1) viewModel.postComments = postComments @@ -306,7 +306,7 @@ final class ThreadViewModelTests: XCTestCase { Given(interactor, .readBody(threadID: .any, willThrow: NSError())) Given(interactor, .getQuestionComments(threadID: .any, page: .any, willThrow: NSError())) - result = await viewModel.getPosts(thread: threads.threads[0], page: 1) + result = await viewModel.getThreadData(thread: threads.threads[0], page: 1) viewModel.postComments = postComments diff --git a/Discussion/DiscussionTests/Presentation/Responses/ResponsesViewModelTests.swift b/Discussion/DiscussionTests/Presentation/Responses/ResponsesViewModelTests.swift index 997364f10..a7c59806a 100644 --- a/Discussion/DiscussionTests/Presentation/Responses/ResponsesViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/Responses/ResponsesViewModelTests.swift @@ -116,7 +116,7 @@ final class ResponsesViewModelTests: XCTestCase { count: 1, numPages: 1)))) - result = await viewModel.getComments(commentID: "1", parentComment: post, page: 1) + result = await viewModel.getResponsesData(commentID: "1", parentComment: post, page: 1) Verify(interactor, .getCommentResponses(commentID: .any, page: .any)) @@ -141,7 +141,7 @@ final class ResponsesViewModelTests: XCTestCase { Given(interactor, .getCommentResponses(commentID: .any, page: .any, willThrow: noInternetError)) - result = await viewModel.getComments(commentID: "1", parentComment: post, page: 1) + result = await viewModel.getResponsesData(commentID: "1", parentComment: post, page: 1) Verify(interactor, .getCommentResponses(commentID: .any, page: .any)) @@ -165,7 +165,7 @@ final class ResponsesViewModelTests: XCTestCase { Given(interactor, .getCommentResponses(commentID: .any, page: .any, willThrow: NSError())) - result = await viewModel.getComments(commentID: "1", parentComment: post, page: 1) + result = await viewModel.getResponsesData(commentID: "1", parentComment: post, page: 1) Verify(interactor, .getCommentResponses(commentID: .any, page: .any)) From e98c38fc63af2c03a4b1f630337ed99785d89269 Mon Sep 17 00:00:00 2001 From: Max Sokolski Date: Mon, 22 Apr 2024 13:42:28 +0300 Subject: [PATCH 135/136] chore: add maintainership documentation (#396) --- catalog-info.yaml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 catalog-info.yaml diff --git a/catalog-info.yaml b/catalog-info.yaml new file mode 100644 index 000000000..c934014ab --- /dev/null +++ b/catalog-info.yaml @@ -0,0 +1,16 @@ +# This file records information about this repo. Its use is described in OEP-55: +# https://open-edx-proposals.readthedocs.io/en/latest/processes/oep-0055-proc-project-maintainers.html + +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: 'openedx-app-ios' + description: "The mobile app for iOS for the Open EdX Platform" + links: + - url: "https://github.com/openedx/openedx-app-ios/tree/main/Documentation" + title: "Documentation" + icon: "PhoneIphone" +spec: + owner: group:openedx-mobile-maintainers + type: 'mobile' + lifecycle: 'production' From a0c4c1778d16a4a8c7cc204c0fc00011b4134785 Mon Sep 17 00:00:00 2001 From: Shafqat Muneer Date: Mon, 22 Apr 2024 16:05:32 +0500 Subject: [PATCH 136/136] fix: Maintain calendar toggle and local calendar event mapping (#411) --- Core/Core/Extensions/DateExtension.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Core/Core/Extensions/DateExtension.swift b/Core/Core/Extensions/DateExtension.swift index ca1a7d73b..8a57079f4 100644 --- a/Core/Core/Extensions/DateExtension.swift +++ b/Core/Core/Extensions/DateExtension.swift @@ -11,6 +11,7 @@ import Foundation public extension Date { init(iso8601: String) { let formats = ["yyyy-MM-dd'T'HH:mm:ssZ", "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ"] + let calender = Calendar.current var date: Date var dateFormatter: DateFormatter? dateFormatter = DateFormatter() @@ -18,7 +19,12 @@ public extension Date { date = formats.compactMap { format in dateFormatter?.dateFormat = format - return dateFormatter?.date(from: iso8601) + guard let formattedDate = dateFormatter?.date(from: iso8601) else { return nil } + let components = calender.dateComponents( + [.year, .month, .day, .hour, .minute, .second], + from: formattedDate + ) + return calender.date(from: components) } .first ?? Date()