From 4706755d4b7ab0a48b55885f46348bff5b8d642a Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Tue, 7 May 2024 16:14:09 +0300 Subject: [PATCH 01/55] feat: [FC-0047] Account Settings and Profile split (#397) * feat: Separate account settings and profile editing * feat: changes according to desing * feat: update background colors * feat: update missed email value in tests * fix: resume old design * fix: changes after the code review * fix: code formatting on ManageAccountView * fix: changes after code review fix navigation titles and back buttons on few screens * feat: updates after shafqat-muneer code review * feat: fixes after review * feat: changes after code review --- .../Presentation/Login/SignInView.swift | 4 +- .../Registration/SignUpView.swift | 2 +- .../Reset Password/ResetPasswordView.swift | 4 +- .../deleteAccount.imageset/Contents.json | 15 + .../deleteAccount.imageset/deleteAccount.svg | 14 + .../settings.imageset/Contents.json | 15 + .../settings.imageset/settingsIcon.svg | 4 + Core/Core/Data/Model/Data_UserProfile.swift | 3 +- Core/Core/Domain/Model/UserProfile.swift | 6 +- Core/Core/SwiftGen/Assets.swift | 2 + Core/Core/View/Base/NavigationBar.swift | 4 +- .../View/Base/VideoDownloadQualityView.swift | 127 +++++---- .../Outline/CourseOutlineView.swift | 3 +- .../VideoDownloadQualityContainerView.swift | 12 +- .../NativeDiscovery/CourseDetailsView.swift | 4 +- .../WebDiscovery/DiscoveryWebview.swift | 2 +- .../WebPrograms/ProgramWebviewView.swift | 2 +- OpenEdX/DI/ScreenAssembly.swift | 16 +- OpenEdX/Router.swift | 17 +- OpenEdX/View/MainScreenView.swift | 36 ++- Profile/Profile.xcodeproj/project.pbxproj | 16 ++ Profile/Profile/Data/ProfileRepository.swift | 12 +- .../DeleteAccount/DeleteAccountView.swift | 2 +- .../EditProfile/EditProfileView.swift | 14 +- .../Presentation/Profile/ProfileView.swift | 260 ++++++------------ .../Profile/ProfileViewModel.swift | 97 +------ .../Subviews/ProfileSupportInfoView.swift | 3 +- .../Profile/Presentation/ProfileRouter.swift | 8 + .../Settings/ManageAccountView.swift | 214 ++++++++++++++ .../Settings/ManageAccountViewModel.swift | 76 +++++ .../Presentation/Settings/SettingsView.swift | 243 +++++++++++----- .../Settings/SettingsViewModel.swift | 111 +++++++- .../Settings/VideoQualityView.swift | 150 ++++++---- .../Settings/VideoSettingsView.swift | 148 ++++++++++ Profile/Profile/SwiftGen/Strings.swift | 16 +- Profile/Profile/en.lproj/Localizable.strings | 8 +- Profile/Profile/uk.lproj/Localizable.strings | 2 + .../EditProfileViewModelTests.swift | 66 +++-- .../Profile/ProfileViewModelTests.swift | 133 +-------- .../Settings/SettingsViewModelTests.swift | 203 ++++++++++++++ .../ProfileTests/ProfileMock.generated.swift | 30 ++ .../Theme/Assets.xcassets/Auth/Contents.json | 6 - .../Contents.json | 0 .../Rectangle-2.png | Bin .../Rectangle.png | Bin Theme/Theme/SwiftGen/ThemeAssets.swift | 2 +- 46 files changed, 1438 insertions(+), 674 deletions(-) create mode 100644 Core/Core/Assets.xcassets/Profile/deleteAccount.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/Profile/deleteAccount.imageset/deleteAccount.svg create mode 100644 Core/Core/Assets.xcassets/settings.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/settings.imageset/settingsIcon.svg create mode 100644 Profile/Profile/Presentation/Settings/ManageAccountView.swift create mode 100644 Profile/Profile/Presentation/Settings/ManageAccountViewModel.swift create mode 100644 Profile/Profile/Presentation/Settings/VideoSettingsView.swift create mode 100644 Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift delete mode 100644 Theme/Theme/Assets.xcassets/Auth/Contents.json rename Theme/Theme/Assets.xcassets/{Auth/authBackground.imageset => headerBackground.imageset}/Contents.json (100%) rename Theme/Theme/Assets.xcassets/{Auth/authBackground.imageset => headerBackground.imageset}/Rectangle-2.png (100%) rename Theme/Theme/Assets.xcassets/{Auth/authBackground.imageset => headerBackground.imageset}/Rectangle.png (100%) diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index 209da1912..20bfcb659 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -27,7 +27,7 @@ public struct SignInView: View { public var body: some View { ZStack(alignment: .top) { VStack { - ThemeAssets.authBackground.swiftUIImage + ThemeAssets.headerBackground.swiftUIImage .resizable() .edgesIgnoringSafeArea(.top) .accessibilityIdentifier("auth_bg_image") @@ -144,7 +144,7 @@ public struct SignInView: View { HStack(alignment: .center) { ProgressBar(size: 40, lineWidth: 8) .padding(20) - .accessibilityIdentifier("progressbar") + .accessibilityIdentifier("progress_bar") }.frame(maxWidth: .infinity) } else { StyledButton(CoreLocalization.SignIn.logInBtn) { diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index 4c6e154c0..2401ad846 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -29,7 +29,7 @@ public struct SignUpView: View { public var body: some View { ZStack(alignment: .top) { VStack { - ThemeAssets.authBackground.swiftUIImage + ThemeAssets.headerBackground.swiftUIImage .resizable() .edgesIgnoringSafeArea(.top) } diff --git a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift index a66562028..27dedad2f 100644 --- a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift +++ b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift @@ -28,7 +28,7 @@ public struct ResetPasswordView: View { GeometryReader { proxy in ZStack(alignment: .top) { VStack { - ThemeAssets.authBackground.swiftUIImage + ThemeAssets.headerBackground.swiftUIImage .resizable() .edgesIgnoringSafeArea(.top) } @@ -117,7 +117,7 @@ public struct ResetPasswordView: View { HStack(alignment: .center) { ProgressBar(size: 40, lineWidth: 8) .padding(20) - .accessibilityIdentifier("progressbar") + .accessibilityIdentifier("progress_bar") }.frame(maxWidth: .infinity) } else { StyledButton(AuthLocalization.Forgot.request) { diff --git a/Core/Core/Assets.xcassets/Profile/deleteAccount.imageset/Contents.json b/Core/Core/Assets.xcassets/Profile/deleteAccount.imageset/Contents.json new file mode 100644 index 000000000..9a8a529ea --- /dev/null +++ b/Core/Core/Assets.xcassets/Profile/deleteAccount.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "deleteAccount.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Core/Core/Assets.xcassets/Profile/deleteAccount.imageset/deleteAccount.svg b/Core/Core/Assets.xcassets/Profile/deleteAccount.imageset/deleteAccount.svg new file mode 100644 index 000000000..9c2a082f2 --- /dev/null +++ b/Core/Core/Assets.xcassets/Profile/deleteAccount.imageset/deleteAccount.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Core/Core/Assets.xcassets/settings.imageset/Contents.json b/Core/Core/Assets.xcassets/settings.imageset/Contents.json new file mode 100644 index 000000000..aa6427af7 --- /dev/null +++ b/Core/Core/Assets.xcassets/settings.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "settingsIcon.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Core/Core/Assets.xcassets/settings.imageset/settingsIcon.svg b/Core/Core/Assets.xcassets/settings.imageset/settingsIcon.svg new file mode 100644 index 000000000..c1181ff8e --- /dev/null +++ b/Core/Core/Assets.xcassets/settings.imageset/settingsIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Core/Core/Data/Model/Data_UserProfile.swift b/Core/Core/Data/Model/Data_UserProfile.swift index d3541cfd2..fe0e675bf 100644 --- a/Core/Core/Data/Model/Data_UserProfile.swift +++ b/Core/Core/Data/Model/Data_UserProfile.swift @@ -133,6 +133,7 @@ public extension DataLayer.UserProfile { country: country ?? "", spokenLanguage: languageProficiencies?[safe: 0]?.code ?? "", shortBiography: bio ?? "", - isFullProfile: accountPrivacy?.boolValue ?? true) + isFullProfile: accountPrivacy?.boolValue ?? true, + email: email ?? "") } } diff --git a/Core/Core/Domain/Model/UserProfile.swift b/Core/Core/Domain/Model/UserProfile.swift index 03b19990a..2ad1b6456 100644 --- a/Core/Core/Domain/Model/UserProfile.swift +++ b/Core/Core/Domain/Model/UserProfile.swift @@ -17,6 +17,7 @@ public struct UserProfile: Hashable { public let spokenLanguage: String? public let shortBiography: String public let isFullProfile: Bool + public let email: String public init( avatarUrl: String, @@ -27,7 +28,8 @@ public struct UserProfile: Hashable { country: String, spokenLanguage: String? = nil, shortBiography: String, - isFullProfile: Bool + isFullProfile: Bool, + email: String ) { self.avatarUrl = avatarUrl self.name = name @@ -38,6 +40,7 @@ public struct UserProfile: Hashable { self.spokenLanguage = spokenLanguage self.shortBiography = shortBiography self.isFullProfile = isFullProfile + self.email = email } public init() { @@ -50,5 +53,6 @@ public struct UserProfile: Hashable { self.spokenLanguage = "" self.shortBiography = "" self.isFullProfile = true + self.email = "" } } diff --git a/Core/Core/SwiftGen/Assets.swift b/Core/Core/SwiftGen/Assets.swift index e25f452d0..a6cb7b057 100644 --- a/Core/Core/SwiftGen/Assets.swift +++ b/Core/Core/SwiftGen/Assets.swift @@ -75,6 +75,7 @@ public enum CoreAssets { public static let addPhoto = ImageAsset(name: "addPhoto") public static let bgDelete = ImageAsset(name: "bg_delete") public static let checkmark = ImageAsset(name: "checkmark") + public static let deleteAccount = ImageAsset(name: "deleteAccount") public static let deleteChar = ImageAsset(name: "delete_char") public static let deleteEyes = ImageAsset(name: "delete_eyes") public static let done = ImageAsset(name: "done") @@ -113,6 +114,7 @@ public enum CoreAssets { public static let noWifiMini = ImageAsset(name: "noWifiMini") public static let notAvaliable = ImageAsset(name: "notAvaliable") public static let playVideo = ImageAsset(name: "playVideo") + public static let settings = ImageAsset(name: "settings") public static let star = ImageAsset(name: "star") public static let starOutline = ImageAsset(name: "star_outline") public static let warning = ImageAsset(name: "warning") diff --git a/Core/Core/View/Base/NavigationBar.swift b/Core/Core/View/Base/NavigationBar.swift index 8815cf2b8..489d31236 100644 --- a/Core/Core/View/Base/NavigationBar.swift +++ b/Core/Core/View/Base/NavigationBar.swift @@ -23,6 +23,7 @@ public struct NavigationBar: View { private let rightButtonType: ButtonType? private let rightButtonAction: (() -> Void)? @Binding private var rightButtonIsActive: Bool + @Environment (\.isHorizontal) private var isHorizontal public init(title: String, titleColor: Color = Theme.Colors.navigationBarTintColor, @@ -53,8 +54,9 @@ public struct NavigationBar: View { if leftButton { VStack { BackNavigationButton(color: leftButtonColor, action: leftButtonAction) - .padding(8) .backViewStyle() + .padding(.leading, isHorizontal ? 48 : 0) + .accessibilityIdentifier("back_button") } .frame(minWidth: 0, maxWidth: .infinity, diff --git a/Core/Core/View/Base/VideoDownloadQualityView.swift b/Core/Core/View/Base/VideoDownloadQualityView.swift index 2401a4f27..06e3f7c5a 100644 --- a/Core/Core/View/Base/VideoDownloadQualityView.swift +++ b/Core/Core/View/Base/VideoDownloadQualityView.swift @@ -33,11 +33,14 @@ public struct VideoDownloadQualityView: View { @StateObject private var viewModel: VideoDownloadQualityViewModel private var analytics: CoreAnalytics + private var router: BaseRouter + @Environment (\.isHorizontal) private var isHorizontal public init( downloadQuality: DownloadQuality, didSelect: ((DownloadQuality) -> Void)?, - analytics: CoreAnalytics + analytics: CoreAnalytics, + router: BaseRouter ) { self._viewModel = StateObject( wrappedValue: .init( @@ -46,64 +49,94 @@ public struct VideoDownloadQualityView: View { ) ) self.analytics = analytics + self.router = router } public var body: some View { 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 ?? "" - ) - - viewModel.selectedDownloadQuality = quality - }, label: { - HStack { - SettingsCell( - title: quality.title, - description: quality.description + VStack { + ThemeAssets.headerBackground.swiftUIImage + .resizable() + .edgesIgnoringSafeArea(.top) + } + .frame(maxWidth: .infinity, maxHeight: 200) + .accessibilityIdentifier("auth_bg_image") + + // MARK: - Page name + VStack(alignment: .center) { + ZStack { + HStack { + Text(CoreLocalization.Settings.videoDownloadQualityTitle) + .titleSettings(color: Theme.Colors.loginNavigationText) + .accessibilityIdentifier("manage_account_text") + } + VStack { + BackNavigationButton( + color: Theme.Colors.loginNavigationText, + action: { + router.back() + } + ) + .backViewStyle() + .padding(.leading, isHorizontal ? 48 : 0) + .accessibilityIdentifier("back_button") + + }.frame(minWidth: 0, + maxWidth: .infinity, + alignment: .topLeading) + } + // 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() + } } + .frameLimit(width: proxy.size.width) + .padding(.horizontal, 24) + .padding(.top, 24) } - .frame( - minWidth: 0, - maxWidth: .infinity, - alignment: .topLeading - ) - .padding(.horizontal, 24) - .frameLimit(width: proxy.size.width) + .roundedBackground(Theme.Colors.background) } - .padding(.top, 8) } - .navigationBarHidden(false) - .navigationBarBackButtonHidden(false) - .navigationTitle(CoreLocalization.Settings.videoDownloadQualityTitle) - .background( - Theme.Colors.background - .ignoresSafeArea() - ) } + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + .navigationTitle(CoreLocalization.Settings.videoDownloadQualityTitle) + .ignoresSafeArea(.all, edges: .horizontal) + .background( + Theme.Colors.background + .ignoresSafeArea() + ) } } diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 18575dc7a..741b76bdf 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -223,7 +223,8 @@ public struct CourseOutlineView: View { VideoDownloadQualityContainerView( downloadQuality: $0.downloadQuality, didSelect: viewModel.update(downloadQuality:), - analytics: viewModel.coreAnalytics + analytics: viewModel.coreAnalytics, + router: viewModel.router ) } } diff --git a/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift b/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift index 4418d3513..09fcf8001 100644 --- a/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift +++ b/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift @@ -16,11 +16,18 @@ struct VideoDownloadQualityContainerView: View { private var downloadQuality: DownloadQuality private var didSelect: ((DownloadQuality) -> Void)? private let analytics: CoreAnalytics + private let router: CourseRouter - init(downloadQuality: DownloadQuality, didSelect: ((DownloadQuality) -> Void)?, analytics: CoreAnalytics) { + init( + downloadQuality: DownloadQuality, + didSelect: ((DownloadQuality) -> Void)?, + analytics: CoreAnalytics, + router: CourseRouter + ) { self.downloadQuality = downloadQuality self.didSelect = didSelect self.analytics = analytics + self.router = router } var body: some View { @@ -28,7 +35,8 @@ struct VideoDownloadQualityContainerView: View { VideoDownloadQualityView( downloadQuality: downloadQuality, didSelect: didSelect, - analytics: analytics + analytics: analytics, + router: router ) .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift index 427cd4ade..2300433ef 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift @@ -47,7 +47,7 @@ public struct CourseDetailsView: View { ProgressBar(size: 40, lineWidth: 8) .padding(.top, 200) .padding(.horizontal) - .accessibilityIdentifier("progressbar") + .accessibilityIdentifier("progress_bar") }.frame(width: proxy.size.width) } else { RefreshableScrollViewCompat(action: { @@ -132,7 +132,7 @@ public struct CourseDetailsView: View { ProgressBar(size: 40, lineWidth: 8) .padding(.top, 20) .frame(maxWidth: .infinity) - .accessibilityIdentifier("progressbar") + .accessibilityIdentifier("progress_bar") } } } diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift index 84052bc6d..be01b0be4 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift @@ -102,7 +102,7 @@ public struct DiscoveryWebview: View { lineWidth: 8 ) .padding(.vertical, proxy.size.height / 2) - .accessibilityIdentifier("progressbar") + .accessibilityIdentifier("progress_bar") } .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 f97dd2c8c..ad28e6938 100644 --- a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift +++ b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift @@ -78,7 +78,7 @@ public struct ProgramWebviewView: View { lineWidth: 8 ) .padding(.vertical, proxy.size.height / 2) - .accessibilityIdentifier("progressbar") + .accessibilityIdentifier("progress_bar") } .frame(width: proxy.size.width, height: proxy.size.height) } diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index ec0ed004c..131cf674b 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -188,7 +188,6 @@ class ScreenAssembly: Assembly { container.register(ProfileViewModel.self) { r in ProfileViewModel( 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)!, @@ -208,8 +207,21 @@ class ScreenAssembly: Assembly { container.register(SettingsViewModel.self) { r in SettingsViewModel( interactor: r.resolve(ProfileInteractorProtocol.self)!, + downloadManager: r.resolve(DownloadManagerProtocol.self)!, router: r.resolve(ProfileRouter.self)!, - analytics: r.resolve(CoreAnalytics.self)! + analytics: r.resolve(ProfileAnalytics.self)!, + coreAnalytics: r.resolve(CoreAnalytics.self)!, + config: r.resolve(ConfigProtocol.self)! + ) + } + + container.register(ManageAccountViewModel.self) { r in + ManageAccountViewModel( + router: r.resolve(ProfileRouter.self)!, + analytics: r.resolve(ProfileAnalytics.self)!, + config: r.resolve(ConfigProtocol.self)!, + connectivity: r.resolve(ConnectivityProtocol.self)!, + interactor: r.resolve(ProfileInteractorProtocol.self)! ) } diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 6c96146b3..80fd75ad6 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -694,6 +694,20 @@ public class Router: AuthorizationRouter, navigationController.pushViewController(controller, animated: true) } + public func showVideoSettings() { + let viewModel = Container.shared.resolve(SettingsViewModel.self)! + let view = VideoSettingsView(viewModel: viewModel) + let controller = UIHostingController(rootView: view) + navigationController.pushViewController(controller, animated: true) + } + + public func showManageAccount() { + let viewModel = Container.shared.resolve(ManageAccountViewModel.self)! + let view = ManageAccountView(viewModel: viewModel) + let controller = UIHostingController(rootView: view) + navigationController.pushViewController(controller, animated: true) + } + public func showVideoQualityView(viewModel: SettingsViewModel) { let view = VideoQualityView(viewModel: viewModel) let controller = UIHostingController(rootView: view) @@ -708,7 +722,8 @@ public class Router: AuthorizationRouter, let view = VideoDownloadQualityView( downloadQuality: downloadQuality, didSelect: didSelect, - analytics: analytics + analytics: analytics, + router: self ) let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) diff --git a/OpenEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift index f51ebc476..98e349542 100644 --- a/OpenEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -17,12 +17,11 @@ import Theme struct MainScreenView: View { - @State private var settingsTapped: Bool = false @State private var disableAllTabs: Bool = false - @State private var updateAvaliable: Bool = false + @State private var updateAvailable: Bool = false @ObservedObject private(set) var viewModel: MainScreenViewModel - + init(viewModel: MainScreenViewModel) { self.viewModel = viewModel UITabBar.appearance().isTranslucent = false @@ -35,7 +34,7 @@ struct MainScreenView: View { for: .normal ) } - + var body: some View { TabView(selection: $viewModel.selection) { let config = Container.shared.resolve(ConfigProtocol.self) @@ -56,7 +55,7 @@ struct MainScreenView: View { ) } - if updateAvaliable { + if updateAvailable { UpdateNotificationView(config: viewModel.config) } } @@ -73,7 +72,7 @@ struct MainScreenView: View { viewModel: Container.shared.resolve(DashboardViewModel.self)!, router: Container.shared.resolve(DashboardRouter.self)! ) - if updateAvaliable { + if updateAvailable { UpdateNotificationView(config: viewModel.config) } } @@ -96,7 +95,7 @@ struct MainScreenView: View { .accessibilityIdentifier("indevelopment_program_text") } - if updateAvaliable { + if updateAvailable { UpdateNotificationView(config: viewModel.config) } } @@ -110,7 +109,7 @@ struct MainScreenView: View { VStack { ProfileView( - viewModel: Container.shared.resolve(ProfileViewModel.self)!, settingsTapped: $settingsTapped + viewModel: Container.shared.resolve(ProfileViewModel.self)! ) } .tabItem { @@ -125,17 +124,14 @@ struct MainScreenView: View { .navigationTitle(titleBar()) .toolbar { ToolbarItem(placement: .navigationBarTrailing, content: { - if viewModel.selection == .profile { - Button(action: { - settingsTapped.toggle() - }, label: { - CoreAssets.edit.swiftUIImage.renderingMode(.template) - .foregroundColor(Theme.Colors.navigationBarTintColor) - }) - .accessibilityIdentifier("edit_profile_button") - } else { - VStack {} - } + Button(action: { + let router = Container.shared.resolve(ProfileRouter.self)! + router.showSettings() + }, label: { + CoreAssets.settings.swiftUIImage.renderingMode(.template) + .foregroundColor(Theme.Colors.accentColor) + }) + .accessibilityIdentifier("edit_profile_button") }) } .onReceive(NotificationCenter.default.publisher(for: .onAppUpgradeAccountSettingsTapped)) { _ in @@ -143,7 +139,7 @@ struct MainScreenView: View { disableAllTabs = true } .onReceive(NotificationCenter.default.publisher(for: .onNewVersionAvaliable)) { _ in - updateAvaliable = true + updateAvailable = true } .onChange(of: viewModel.selection) { _ in if disableAllTabs { diff --git a/Profile/Profile.xcodeproj/project.pbxproj b/Profile/Profile.xcodeproj/project.pbxproj index f4ebc7e78..a9a09fc33 100644 --- a/Profile/Profile.xcodeproj/project.pbxproj +++ b/Profile/Profile.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 020102D129784B3100BBF80C /* EditProfileViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 020102D029784B3100BBF80C /* EditProfileViewModelTests.swift */; }; 020306C82932B13F000949EA /* EditProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 020306C72932B13F000949EA /* EditProfileView.swift */; }; 020306CA2932B14D000949EA /* EditProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 020306C92932B14D000949EA /* EditProfileViewModel.swift */; }; + 020CBD8D2BC53E1B003D6B4E /* VideoSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 020CBD8C2BC53E1B003D6B4E /* VideoSettingsView.swift */; }; 021D924628DC634300ACC565 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D924528DC634300ACC565 /* ProfileView.swift */; }; 021D924C28DC884A00ACC565 /* ProfileEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D924B28DC884A00ACC565 /* ProfileEndpoint.swift */; }; 021D924E28DC88BB00ACC565 /* ProfileRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D924D28DC88BB00ACC565 /* ProfileRepository.swift */; }; @@ -24,6 +25,8 @@ 025DE1A028DB4D9D0053E0F4 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 025DE19F28DB4D9D0053E0F4 /* Core.framework */; }; 0262149229AE57A1008BD75A /* DeleteAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0262149129AE57A1008BD75A /* DeleteAccountView.swift */; }; 0262149429AE57B1008BD75A /* DeleteAccountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0262149329AE57B1008BD75A /* DeleteAccountViewModel.swift */; }; + 0288C4EA2BC6AE2D009158B9 /* ManageAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0288C4E92BC6AE2D009158B9 /* ManageAccountView.swift */; }; + 0288C4EC2BC6AE82009158B9 /* ManageAccountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0288C4EB2BC6AE82009158B9 /* ManageAccountViewModel.swift */; }; 029301DA2938948500E99AB8 /* ProfileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029301D92938948500E99AB8 /* ProfileType.swift */; }; 02A4833329B7710A00D33F33 /* DeleteAccountViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833229B7710A00D33F33 /* DeleteAccountViewModelTests.swift */; }; 02A9A91D2978194A00B55797 /* ProfileViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A9A91C2978194A00B55797 /* ProfileViewModelTests.swift */; }; @@ -34,6 +37,7 @@ 02D0FD0B2AD6984D0020D752 /* UserProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D0FD0A2AD6984D0020D752 /* UserProfileViewModel.swift */; }; 02F175352A4DAD030019CD70 /* ProfileAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F175342A4DAD030019CD70 /* ProfileAnalytics.swift */; }; 02F3BFE7292539850051930C /* ProfileRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F3BFE6292539850051930C /* ProfileRouter.swift */; }; + 02FE9A802BC707D500B3C206 /* SettingsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FE9A7F2BC707D400B3C206 /* SettingsViewModelTests.swift */; }; 0796C8C929B7905300444B05 /* ProfileBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0796C8C829B7905300444B05 /* ProfileBottomSheet.swift */; }; 25B36FF48C1307888A3890DA /* Pods_App_Profile.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BEA369C38362C1A91A012F70 /* Pods_App_Profile.framework */; }; BAD9CA3F2B29BF5C00DE790A /* ProfileSupportInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA3E2B29BF5C00DE790A /* ProfileSupportInfoView.swift */; }; @@ -54,6 +58,7 @@ 020102D029784B3100BBF80C /* EditProfileViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditProfileViewModelTests.swift; sourceTree = ""; }; 020306C72932B13F000949EA /* EditProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditProfileView.swift; sourceTree = ""; }; 020306C92932B14D000949EA /* EditProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditProfileViewModel.swift; sourceTree = ""; }; + 020CBD8C2BC53E1B003D6B4E /* VideoSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoSettingsView.swift; sourceTree = ""; }; 020F834A28DB4CCD0062FA70 /* Profile.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Profile.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 021D924528DC634300ACC565 /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; 021D924B28DC884A00ACC565 /* ProfileEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileEndpoint.swift; sourceTree = ""; }; @@ -69,6 +74,8 @@ 025DE19F28DB4D9D0053E0F4 /* Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0262149129AE57A1008BD75A /* DeleteAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAccountView.swift; sourceTree = ""; }; 0262149329AE57B1008BD75A /* DeleteAccountViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAccountViewModel.swift; sourceTree = ""; }; + 0288C4E92BC6AE2D009158B9 /* ManageAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageAccountView.swift; sourceTree = ""; }; + 0288C4EB2BC6AE82009158B9 /* ManageAccountViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageAccountViewModel.swift; sourceTree = ""; }; 029301D92938948500E99AB8 /* ProfileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileType.swift; sourceTree = ""; }; 02A4833229B7710A00D33F33 /* DeleteAccountViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DeleteAccountViewModelTests.swift; path = ProfileTests/Presentation/DeleteAccount/DeleteAccountViewModelTests.swift; sourceTree = SOURCE_ROOT; }; 02A9A91A2978194A00B55797 /* ProfileTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ProfileTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -80,6 +87,7 @@ 02ED50CE29A64BAD008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02F175342A4DAD030019CD70 /* ProfileAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAnalytics.swift; sourceTree = ""; }; 02F3BFE6292539850051930C /* ProfileRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRouter.swift; sourceTree = ""; }; + 02FE9A7F2BC707D400B3C206 /* SettingsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SettingsViewModelTests.swift; path = ProfileTests/Presentation/Settings/SettingsViewModelTests.swift; sourceTree = SOURCE_ROOT; }; 0796C8C829B7905300444B05 /* ProfileBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileBottomSheet.swift; sourceTree = ""; }; 0E5054C44435557666B6D885 /* Pods-App-Profile.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Profile.debugstage.xcconfig"; path = "Target Support Files/Pods-App-Profile/Pods-App-Profile.debugstage.xcconfig"; sourceTree = ""; }; 3674C51E1BE41D834B5C4E99 /* Pods-App-Profile.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Profile.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Profile/Pods-App-Profile.debugdev.xcconfig"; sourceTree = ""; }; @@ -239,6 +247,9 @@ isa = PBXGroup; children = ( 0259104329C39C9E004B5A55 /* SettingsView.swift */, + 0288C4E92BC6AE2D009158B9 /* ManageAccountView.swift */, + 0288C4EB2BC6AE82009158B9 /* ManageAccountViewModel.swift */, + 020CBD8C2BC53E1B003D6B4E /* VideoSettingsView.swift */, 0259104729C3A5F0004B5A55 /* VideoQualityView.swift */, 0259104529C39CCF004B5A55 /* SettingsViewModel.swift */, ); @@ -257,6 +268,7 @@ 02A4832F29B770B600D33F33 /* Profile */ = { isa = PBXGroup; children = ( + 02FE9A7F2BC707D400B3C206 /* SettingsViewModelTests.swift */, 02A9A91C2978194A00B55797 /* ProfileViewModelTests.swift */, ); path = Profile; @@ -572,6 +584,7 @@ buildActionMask = 2147483647; files = ( 021D924E28DC88BB00ACC565 /* ProfileRepository.swift in Sources */, + 0288C4EC2BC6AE82009158B9 /* ManageAccountViewModel.swift in Sources */, 0796C8C929B7905300444B05 /* ProfileBottomSheet.swift in Sources */, 021D924C28DC884A00ACC565 /* ProfileEndpoint.swift in Sources */, BAD9CA3F2B29BF5C00DE790A /* ProfileSupportInfoView.swift in Sources */, @@ -587,9 +600,11 @@ 0248F9B128DDB09D0041327E /* Strings.swift in Sources */, 020306CA2932B14D000949EA /* EditProfileViewModel.swift in Sources */, 0259104829C3A5F0004B5A55 /* VideoQualityView.swift in Sources */, + 0288C4EA2BC6AE2D009158B9 /* ManageAccountView.swift in Sources */, 021D924628DC634300ACC565 /* ProfileView.swift in Sources */, 02F3BFE7292539850051930C /* ProfileRouter.swift in Sources */, 02D0FD0B2AD6984D0020D752 /* UserProfileViewModel.swift in Sources */, + 020CBD8D2BC53E1B003D6B4E /* VideoSettingsView.swift in Sources */, 02F175352A4DAD030019CD70 /* ProfileAnalytics.swift in Sources */, 0262149429AE57B1008BD75A /* DeleteAccountViewModel.swift in Sources */, ); @@ -601,6 +616,7 @@ files = ( 02A9A92B29781A6300B55797 /* ProfileMock.generated.swift in Sources */, 02A4833329B7710A00D33F33 /* DeleteAccountViewModelTests.swift in Sources */, + 02FE9A802BC707D500B3C206 /* SettingsViewModelTests.swift in Sources */, 02A9A91D2978194A00B55797 /* ProfileViewModelTests.swift in Sources */, 020102D129784B3100BBF80C /* EditProfileViewModelTests.swift in Sources */, ); diff --git a/Profile/Profile/Data/ProfileRepository.swift b/Profile/Profile/Data/ProfileRepository.swift index 7608ff849..ffeca922d 100644 --- a/Profile/Profile/Data/ProfileRepository.swift +++ b/Profile/Profile/Data/ProfileRepository.swift @@ -164,7 +164,8 @@ class ProfileRepositoryMock: ProfileRepositoryProtocol { yearOfBirth: 0, country: "", shortBiography: "", - isFullProfile: false) + isFullProfile: false, + email: "") } func getMyProfileOffline() -> Core.UserProfile? { @@ -182,7 +183,8 @@ class ProfileRepositoryMock: ProfileRepositoryProtocol { of his music, writing and drawings, on film, and in interviews. His songwriting partnership with Paul McCartney remains the most successful in history """, - isFullProfile: true + isFullProfile: true, + email: "" ) } @@ -201,7 +203,8 @@ class ProfileRepositoryMock: ProfileRepositoryProtocol { of his music, writing and drawings, on film, and in interviews. His songwriting partnership with Paul McCartney remains the most successful in history """, - isFullProfile: true + isFullProfile: true, + email: "" ) } @@ -224,7 +227,8 @@ class ProfileRepositoryMock: ProfileRepositoryProtocol { yearOfBirth: 1970, country: "USA", shortBiography: "Bio", - isFullProfile: true + isFullProfile: true, + email: "" ) } diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift index c2f5dc7fb..044e4eb18 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift @@ -104,7 +104,7 @@ public struct DeleteAccountView: View { ProgressBar(size: 40, lineWidth: 8) .padding(.top, 20) .padding(.horizontal) - .accessibilityIdentifier("progressbar") + .accessibilityIdentifier("progress_bar") } else { StyledButton( ProfileLocalization.DeleteAccount.comfirm, diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift index 9e2fe176f..2ec255976 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift @@ -123,15 +123,6 @@ public struct EditProfileView: View { } }) - 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) @@ -204,7 +195,7 @@ public struct EditProfileView: View { ProgressBar(size: 40, lineWidth: 8) .padding(.top, 150) .padding(.horizontal) - .accessibilityIdentifier("progressbar") + .accessibilityIdentifier("progress_bar") } } .navigationBarHidden(false) @@ -260,7 +251,8 @@ struct EditProfileView_Previews: PreviewProvider { yearOfBirth: 0, country: "Ukraine", shortBiography: "", - isFullProfile: true + isFullProfile: true, + email: "peter@example.org" ) EditProfileView( diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index 03c920b2f..b643845bd 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -11,15 +11,13 @@ import Kingfisher import Theme public struct ProfileView: View { - + @StateObject private var viewModel: ProfileViewModel - @Binding var settingsTapped: Bool - - public init(viewModel: ProfileViewModel, settingsTapped: Binding) { + + public init(viewModel: ProfileViewModel) { self._viewModel = StateObject(wrappedValue: { viewModel }()) - self._settingsTapped = settingsTapped } - + public var body: some View { GeometryReader { proxy in ZStack(alignment: .top) { @@ -35,24 +33,9 @@ public struct ProfileView: View { ) .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) + .navigationTitle(ProfileLocalization.title) // MARK: - Offline mode SnackBar OfflineSnackBarView( @@ -81,7 +64,7 @@ public struct ProfileView: View { } } } - .onFirstAppear { + .onAppear { Task { await viewModel.getMyProfile() } @@ -97,170 +80,96 @@ public struct ProfileView: View { } } } - + private var progressBar: some View { ProgressBar(size: 40, lineWidth: 8) .padding(.top, 200) .padding(.horizontal) } - + + private var editProfileButton: some View { + StyledButton( + ProfileLocalization.editProfile, + action: { + 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 + } + } + ) + }, + color: .clear, + textColor: Theme.Colors.accentColor, + borderColor: Theme.Colors.accentColor + ).padding(.all, 24) + } + private var content: some View { VStack { if viewModel.isShowProgress { ProgressBar(size: 40, lineWidth: 8) .padding(.top, 200) .padding(.horizontal) - .accessibilityIdentifier("progressbar") + .accessibilityIdentifier("progress_bar") } else { - UserAvatar(url: viewModel.userModel?.avatarUrl ?? "", image: $viewModel.updatedAvatar) - .padding(.top, 30) - .accessibilityIdentifier("user_avatar_image") - Text(viewModel.userModel?.name ?? "") - .font(Theme.Fonts.headlineSmall) - .foregroundColor(Theme.Colors.textPrimary) - .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") + HStack(alignment: .center, spacing: 12) { + UserAvatar(url: viewModel.userModel?.avatarUrl ?? "", image: $viewModel.updatedAvatar) + .accessibilityIdentifier("user_avatar_image") + VStack(alignment: .leading, spacing: 4) { + Text(viewModel.userModel?.name ?? "") + .font(Theme.Fonts.headlineSmall) + .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("user_name_text") + Text("@\(viewModel.userModel?.username ?? "")") + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textSecondary) + .accessibilityIdentifier("user_username_text") + } + Spacer() + }.padding(.all, 24) profileInfo - VStack(alignment: .leading, spacing: 14) { - settings - ProfileSupportInfoView(viewModel: viewModel) - logOutButton - } + editProfileButton Spacer() } } } - + // MARK: - Profile Info - @ViewBuilder private var profileInfo: some View { - 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) + if let bio = viewModel.userModel?.shortBiography, bio != "" { + VStack(alignment: .leading, spacing: 6) { + Text(ProfileLocalization.about) + .font(Theme.Fonts.titleSmall) + .foregroundColor(Theme.Colors.textPrimary) .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)) - .foregroundColor(Theme.Colors.textPrimary) - .accessibilityIdentifier("yob_value_text") - } - .font(Theme.Fonts.titleMedium) - } - if let bio = viewModel.userModel?.shortBiography, bio != "" { - HStack(alignment: .top) { - Text(ProfileLocalization.bio + " ") - .foregroundColor(Theme.Colors.textPrimary) - + Text(bio) - } - .accessibilityIdentifier("bio_text") - } - } - .accessibilityElement(children: .ignore) - .accessibilityLabel( - (viewModel.userModel?.yearOfBirth != 0 ? - ProfileLocalization.Edit.Fields.yearOfBirth + String(viewModel.userModel?.yearOfBirth ?? 0) : - "") + - (viewModel.userModel?.shortBiography != nil ? - ProfileLocalization.bio + (viewModel.userModel?.shortBiography ?? "") : - "") - ) - .cardStyle( - bgColor: Theme.Colors.textInputUnfocusedBackground, - strokeColor: .clear - ) - }.padding(.bottom, 16) - } - } - - // MARK: - Settings - - @ViewBuilder - private var settings: some View { - Text(ProfileLocalization.settings) - .padding(.horizontal, 24) - .font(Theme.Fonts.labelLarge) - .foregroundColor(Theme.Colors.textSecondary) - .accessibilityIdentifier("settings_text") - - VStack(alignment: .leading, spacing: 27) { - Button(action: { - viewModel.trackProfileVideoSettingsClicked() - viewModel.router.showSettings() - }, label: { - HStack { - Text(ProfileLocalization.settingsVideo) - .font(Theme.Fonts.titleMedium) - Spacer() - Image(systemName: "chevron.right") - } - }) - .accessibilityIdentifier("video_settings_button") - - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(ProfileLocalization.settingsVideo) - .cardStyle( - bgColor: Theme.Colors.textInputUnfocusedBackground, - strokeColor: .clear - ) - } - - // MARK: - Log out - - private var logOutButton: some View { - VStack { - Button(action: { - viewModel.trackLogoutClickedClicked() - viewModel.router.presentView( - transitionStyle: .crossDissolve, - animated: true - ) { - AlertView( - alertTitle: ProfileLocalization.LogoutAlert.title, - alertMessage: ProfileLocalization.LogoutAlert.text, - positiveAction: CoreLocalization.Alert.accept, - onCloseTapped: { - viewModel.router.dismiss(animated: true) - }, - okTapped: { - viewModel.router.dismiss(animated: true) - Task { - await viewModel.logOut() - } - }, type: .logOut - ) - } - }, label: { - HStack { - Text(ProfileLocalization.logout) - Spacer() - Image(systemName: "rectangle.portrait.and.arrow.right") - } - }) + Text(bio) + .font(Theme.Fonts.bodyMedium) + .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("bio_text") + } .accessibilityElement(children: .ignore) - .accessibilityLabel(ProfileLocalization.logout) - .accessibilityIdentifier("logout_button") + .accessibilityLabel( + (viewModel.userModel?.yearOfBirth != 0 ? + ProfileLocalization.Edit.Fields.yearOfBirth + String(viewModel.userModel?.yearOfBirth ?? 0) : + "") + + (viewModel.userModel?.shortBiography != nil ? + ProfileLocalization.bio + (viewModel.userModel?.shortBiography ?? "") : + "") + ) + .cardStyle( + bgColor: Theme.Colors.textInputUnfocusedBackground, + strokeColor: .clear + ) } - .foregroundColor(Theme.Colors.alert) - .cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground, - strokeColor: .clear) - .padding(.top, 24) - .padding(.bottom, 60) } } @@ -270,19 +179,18 @@ struct ProfileView_Previews: PreviewProvider { let router = ProfileRouterMock() let vm = ProfileViewModel( interactor: ProfileInteractor.mock, - downloadManager: DownloadManagerMock(), router: router, analytics: ProfileAnalyticsMock(), config: ConfigMock(), connectivity: Connectivity() ) - - ProfileView(viewModel: vm, settingsTapped: .constant(false)) + + ProfileView(viewModel: vm) .preferredColorScheme(.light) .previewDisplayName("DiscoveryView Light") .loadFonts() - - ProfileView(viewModel: vm, settingsTapped: .constant(false)) + + ProfileView(viewModel: vm) .preferredColorScheme(.dark) .previewDisplayName("DiscoveryView Dark") .loadFonts() @@ -291,10 +199,8 @@ struct ProfileView_Previews: PreviewProvider { #endif struct UserAvatar: View { - private var url: URL? @Binding private var image: UIImage? - init(url: String, image: Binding) { if let rightUrl = URL(string: url) { self.url = rightUrl @@ -303,25 +209,21 @@ struct UserAvatar: View { } self._image = image } - var body: some View { ZStack { - Circle() - .foregroundColor(Theme.Colors.avatarStroke) - .frame(width: 104, height: 104) if let image { Image(uiImage: image) .resizable() .scaledToFill() - .frame(width: 100, height: 100) - .cornerRadius(50) + .frame(width: 80, height: 80) + .cornerRadius(40) } else { KFImage(url) .onFailureImage(CoreAssets.noCourseImage.image) .resizable() .scaledToFill() - .frame(width: 100, height: 100) - .cornerRadius(50) + .frame(width: 80, height: 80) + .cornerRadius(40) } } } diff --git a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift index d509224a6..39854471e 100644 --- a/Profile/Profile/Presentation/Profile/ProfileViewModel.swift +++ b/Profile/Profile/Presentation/Profile/ProfileViewModel.swift @@ -22,79 +22,28 @@ public class ProfileViewModel: ObservableObject { } } } - - private var cancellables = Set() - - enum VersionState { - case actual - case updateNeeded - case updateRequired - } - - @Published var versionState: VersionState = .actual - @Published var currentVersion: String = "" - @Published var latestVersion: String = "" let router: ProfileRouter let config: ConfigProtocol 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 self.connectivity = connectivity - generateVersionState() - } - - func openAppStore() { - guard let appStoreURL = URL(string: config.appStoreLink) else { return } - UIApplication.shared.open(appStoreURL) - } - - func generateVersionState() { - guard let info = Bundle.main.infoDictionary else { return } - guard let currentVersion = info["CFBundleShortVersionString"] as? String else { return } - self.currentVersion = currentVersion - NotificationCenter.default.publisher(for: .onActualVersionReceived) - .sink { [weak self] notification in - guard let latestVersion = notification.object as? String else { return } - DispatchQueue.main.async { [weak self] in - self?.latestVersion = latestVersion - - if latestVersion != currentVersion { - self?.versionState = .updateNeeded - } - } - }.store(in: &cancellables) } - - func contactSupport() -> URL? { - let osVersion = UIDevice.current.systemVersion - let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" - let deviceModel = UIDevice.current.model - let feedbackDetails = "OS version: \(osVersion)\nApp version: \(appVersion)\nDevice model: \(deviceModel)" - - let recipientAddress = config.feedbackEmail - let emailSubject = "Feedback" - let emailBody = "\n\n\(feedbackDetails)\n".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)! - let emailURL = URL(string: "mailto:\(recipientAddress)?subject=\(emailSubject)&body=\(emailBody)") - return emailURL - } - + @MainActor public func getMyProfile(withProgress: Bool = true) async { do { @@ -110,9 +59,7 @@ public class ProfileViewModel: ObservableObject { isShowProgress = false } catch let error { isShowProgress = false - if error.isUpdateRequeiredError { - self.versionState = .updateRequired - } else if error.isInternetError { + if error.isInternetError { errorMessage = CoreLocalization.Error.slowOrNoInternetConnection } else { errorMessage = CoreLocalization.Error.unknownError @@ -120,47 +67,7 @@ public class ProfileViewModel: ObservableObject { } } - @MainActor - func logOut() async { - try? await interactor.logOut() - try? await downloadManager.cancelAllDownloading() - router.showStartupScreen() - analytics.userLogout(force: false) - } - - func trackProfileVideoSettingsClicked() { - analytics.profileVideoSettingsClicked() - } - - func trackEmailSupportClicked() { - analytics.emailSupportClicked() - } - - func trackCookiePolicyClicked() { - analytics.cookiePolicyClicked() - } - - func trackTOSClicked() { - analytics.tosClicked() - } - - func trackFAQClicked() { - analytics.faqClicked() - } - - func trackDataSellClicked() { - analytics.dataSellClicked() - } - - func trackPrivacyPolicyClicked() { - analytics.privacyPolicyClicked() - } - 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 1b8c2ae63..5b8f74713 100644 --- a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift +++ b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift @@ -20,7 +20,7 @@ struct ProfileSupportInfoView: View { let title: String } - @ObservedObject var viewModel: ProfileViewModel + @ObservedObject var viewModel: SettingsViewModel var body: some View { Text(ProfileLocalization.supportInfo) @@ -28,6 +28,7 @@ struct ProfileSupportInfoView: View { .font(Theme.Fonts.labelLarge) .foregroundColor(Theme.Colors.textSecondary) .accessibilityIdentifier("support_info_text") + .padding(.top, 12) VStack(alignment: .leading, spacing: 24) { viewModel.contactSupport().map(supportInfo) diff --git a/Profile/Profile/Presentation/ProfileRouter.swift b/Profile/Profile/Presentation/ProfileRouter.swift index 8d9539e92..624a05e21 100644 --- a/Profile/Profile/Presentation/ProfileRouter.swift +++ b/Profile/Profile/Presentation/ProfileRouter.swift @@ -20,6 +20,10 @@ public protocol ProfileRouter: BaseRouter { func showSettings() + func showVideoSettings() + + func showManageAccount() + func showVideoQualityView(viewModel: SettingsViewModel) func showVideoDownloadQualityView( @@ -46,6 +50,10 @@ public class ProfileRouterMock: BaseRouterMock, ProfileRouter { public func showSettings() {} + public func showVideoSettings() {} + + public func showManageAccount() {} + public func showVideoQualityView(viewModel: SettingsViewModel) {} public func showVideoDownloadQualityView( diff --git a/Profile/Profile/Presentation/Settings/ManageAccountView.swift b/Profile/Profile/Presentation/Settings/ManageAccountView.swift new file mode 100644 index 000000000..f4a38ab34 --- /dev/null +++ b/Profile/Profile/Presentation/Settings/ManageAccountView.swift @@ -0,0 +1,214 @@ +// +// ManageAccountView.swift +// Profile +// +// Created by  Stepanok Ivan on 10.04.2024. +// + +import SwiftUI +import Core +import Theme + +public struct ManageAccountView: View { + + @ObservedObject + private var viewModel: ManageAccountViewModel + + @Environment (\.isHorizontal) private var isHorizontal + + public init(viewModel: ManageAccountViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + GeometryReader { proxy in + ZStack(alignment: .top) { + VStack { + ThemeAssets.headerBackground.swiftUIImage + .resizable() + .edgesIgnoringSafeArea(.top) + } + .frame(maxWidth: .infinity, maxHeight: 200) + .accessibilityIdentifier("auth_bg_image") + + // MARK: - Page name + VStack(alignment: .center) { + ZStack { + HStack { + Text(ProfileLocalization.manageAccount) + .titleSettings(color: Theme.Colors.loginNavigationText) + .accessibilityIdentifier("manage_account_text") + } + VStack { + BackNavigationButton( + color: Theme.Colors.loginNavigationText, + action: { + viewModel.router.back() + } + ) + .backViewStyle() + .padding(.leading, isHorizontal ? 48 : 0) + .accessibilityIdentifier("back_button") + + }.frame(minWidth: 0, + maxWidth: .infinity, + alignment: .topLeading) + } + + // MARK: - Page Body + RefreshableScrollViewCompat( + action: { + await viewModel.getMyProfile(withProgress: false) + }, + content: { + VStack(alignment: .leading, spacing: 12) { + if viewModel.isShowProgress { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 200) + .padding(.horizontal) + .accessibilityIdentifier("progress_bar") + } else { + userAvatar + editProfileButton + deleteAccount + } + } + }) + .frameLimit(width: proxy.size.width) + .padding(.top, 24) + .padding(.horizontal, isHorizontal ? 24 : 0) + .roundedBackground(Theme.Colors.background) + } + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + .navigationTitle(ProfileLocalization.manageAccount) + + // 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) + } + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } + } + } + } + } + .background( + Theme.Colors.background + .ignoresSafeArea() + ) + .ignoresSafeArea(.all, edges: .horizontal) + .onFirstAppear { + Task { + await viewModel.getMyProfile() + } + } + } + + private var userAvatar: some View { + HStack(alignment: .center, spacing: 12) { + UserAvatar(url: viewModel.userModel?.avatarUrl ?? "", image: $viewModel.updatedAvatar) + .accessibilityIdentifier("user_avatar_image") + VStack(alignment: .leading, spacing: 4) { + Text(viewModel.userModel?.name ?? "") + .font(Theme.Fonts.headlineSmall) + .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("user_name_text") + Text("\(viewModel.userModel?.email ?? "")") + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textSecondary) + .accessibilityIdentifier("user_username_text") + } + Spacer() + }.padding(.all, 24) + .frame( + minWidth: 0, + maxWidth: .infinity, + alignment: .center + ) + } + + private var deleteAccount: some View { + Button(action: { + viewModel.trackProfileDeleteAccountClicked() + viewModel.router.showDeleteProfileView() + }, label: { + HStack { + CoreAssets.deleteAccount.swiftUIImage + Text(ProfileLocalization.Edit.deleteAccount) + } + }) + .frame( + minWidth: 0, + maxWidth: .infinity, + alignment: .center + ) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.alert) + .padding(.top, 12) + .accessibilityIdentifier("delete_account_button") + } + + private var editProfileButton: some View { + HStack(alignment: .center) { + StyledButton( + ProfileLocalization.editProfile, + action: { + 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 + } + } + ) + }, + color: .clear, + textColor: Theme.Colors.accentColor, + borderColor: Theme.Colors.accentColor + ).padding(.horizontal, 24) + } + .frame( + minWidth: 0, + maxWidth: .infinity, + alignment: .center + ) + } +} + +#if DEBUG +struct ManageAccountView_Previews: PreviewProvider { + static var previews: some View { + let router = ProfileRouterMock() + let vm = ManageAccountViewModel( + router: router, + analytics: ProfileAnalyticsMock(), + config: ConfigMock(), + connectivity: Connectivity(), + interactor: ProfileInteractor.mock + ) + + ManageAccountView(viewModel: vm) + .loadFonts() + } +} +#endif diff --git a/Profile/Profile/Presentation/Settings/ManageAccountViewModel.swift b/Profile/Profile/Presentation/Settings/ManageAccountViewModel.swift new file mode 100644 index 000000000..55014a340 --- /dev/null +++ b/Profile/Profile/Presentation/Settings/ManageAccountViewModel.swift @@ -0,0 +1,76 @@ +// +// ManageAccountViewModel.swift +// Profile +// +// Created by  Stepanok Ivan on 10.04.2024. +// + +import Foundation +import Core +import SwiftUI + +public class ManageAccountViewModel: ObservableObject { + + @Published public var userModel: UserProfile? + @Published public var updatedAvatar: UIImage? + @Published private(set) var isShowProgress = false + @Published var showError: Bool = false + var errorMessage: String? { + didSet { + withAnimation { + showError = errorMessage != nil + } + } + } + + let router: ProfileRouter + let analytics: ProfileAnalytics + let config: ConfigProtocol + let connectivity: ConnectivityProtocol + private let interactor: ProfileInteractorProtocol + + public init( + router: ProfileRouter, + analytics: ProfileAnalytics, + config: ConfigProtocol, + connectivity: ConnectivityProtocol, + interactor: ProfileInteractorProtocol + ) { + self.router = router + self.analytics = analytics + self.config = config + self.connectivity = connectivity + self.interactor = interactor + } + + @MainActor + public func getMyProfile(withProgress: Bool = true) async { + do { + let userModel = interactor.getMyProfileOffline() + if userModel == nil && connectivity.isInternetAvaliable { + isShowProgress = withProgress + } else { + self.userModel = userModel + } + if connectivity.isInternetAvaliable { + self.userModel = try await interactor.getMyProfile() + } + isShowProgress = false + } catch let error { + isShowProgress = false + if error.isInternetError { + errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } else { + errorMessage = CoreLocalization.Error.unknownError + } + } + } + + func trackProfileDeleteAccountClicked() { + analytics.profileDeleteAccountClicked() + } + + func trackProfileEditClicked() { + analytics.profileEditClicked() + } +} diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index 20b6f1e1e..047e238ad 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -15,6 +15,8 @@ public struct SettingsView: View { @ObservedObject private var viewModel: SettingsViewModel + @Environment (\.isHorizontal) private var isHorizontal + public init(viewModel: SettingsViewModel) { self.viewModel = viewModel } @@ -22,79 +24,67 @@ public struct SettingsView: View { public var body: some View { GeometryReader { proxy in ZStack(alignment: .top) { + VStack { + ThemeAssets.headerBackground.swiftUIImage + .resizable() + .edgesIgnoringSafeArea(.top) + } + .frame(maxWidth: .infinity, maxHeight: 200) + .accessibilityIdentifier("auth_bg_image") - // 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: { - SettingsCell( - title: CoreLocalization.Settings.videoDownloadQualityTitle, - description: viewModel.userSettings.downloadQuality.settingsDescription - ) + // MARK: - Page name + VStack(alignment: .center) { + ZStack { + HStack { + Text(ProfileLocalization.settings) + .titleSettings(color: Theme.Colors.loginNavigationText) + .accessibilityIdentifier("register_text") + } + VStack { + BackNavigationButton( + color: Theme.Colors.loginNavigationText, + action: { + viewModel.router.back() } - .accessibilityIdentifier("video_download_quality_button") - // Spacer() - Image(systemName: "chevron.right") - .padding(.trailing, 12) - .frame(width: 10) - .accessibilityIdentifier("video_download_quality_image") + ) + .backViewStyle() + .padding(.leading, isHorizontal ? 48 : 0) + .accessibilityIdentifier("back_button") + + }.frame(minWidth: 0, + maxWidth: .infinity, + alignment: .topLeading) + } + + // MARK: - Page Body + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if viewModel.isShowProgress { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 200) + .padding(.horizontal) + .accessibilityIdentifier("progress_bar") + } else { + manageAccount + settings + ProfileSupportInfoView(viewModel: viewModel) + logOutButton } - Divider() } + .frame( + minWidth: 0, + maxWidth: .infinity, + alignment: .topLeading + ) + .frameLimit(width: proxy.size.width) + .padding(.top, 24) + .padding(.horizontal, isHorizontal ? 24 : 0) } - .frame( - minWidth: 0, - maxWidth: .infinity, - alignment: .topLeading - ) - .padding(.horizontal, 24) - .frameLimit(width: proxy.size.width) + .roundedBackground(Theme.Colors.background) } - .padding(.top, 8) + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + .navigationTitle(ProfileLocalization.settings) // MARK: - Error Alert if viewModel.showError { @@ -110,14 +100,114 @@ public struct SettingsView: View { } } } - .navigationBarHidden(false) - .navigationBarBackButtonHidden(false) - .navigationTitle(ProfileLocalization.Settings.videoSettingsTitle) - .background( - Theme.Colors.background - .ignoresSafeArea() - ) } + .background( + Theme.Colors.background + .ignoresSafeArea() + ) + .ignoresSafeArea(.all, edges: .horizontal) + } + + // MARK: - Manage Account + @ViewBuilder + private var manageAccount: some View { + VStack(alignment: .leading, spacing: 27) { + Button(action: { + viewModel.trackProfileVideoSettingsClicked() + viewModel.router.showManageAccount() + }, label: { + HStack { + Text(ProfileLocalization.manageAccount) + .font(Theme.Fonts.titleMedium) + Spacer() + Image(systemName: "chevron.right") + } + }) + .accessibilityIdentifier("video_settings_button") + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(ProfileLocalization.settingsVideo) + .cardStyle( + bgColor: Theme.Colors.textInputUnfocusedBackground, + strokeColor: .clear + ) + } + + // MARK: - Settings + + @ViewBuilder + private var settings: some View { + Text(ProfileLocalization.settings) + .padding(.horizontal, 24) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textSecondary) + .accessibilityIdentifier("settings_text") + .padding(.top, 12) + + VStack(alignment: .leading, spacing: 27) { + Button(action: { + viewModel.trackProfileVideoSettingsClicked() + viewModel.router.showVideoSettings() + }, label: { + HStack { + Text(ProfileLocalization.settingsVideo) + .font(Theme.Fonts.titleMedium) + Spacer() + Image(systemName: "chevron.right") + } + }) + .accessibilityIdentifier("video_settings_button") + + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(ProfileLocalization.settingsVideo) + .cardStyle( + bgColor: Theme.Colors.textInputUnfocusedBackground, + strokeColor: .clear + ) + } + + // MARK: - Log out + + private var logOutButton: some View { + VStack { + Button(action: { + viewModel.trackLogoutClickedClicked() + viewModel.router.presentView( + transitionStyle: .crossDissolve, + animated: true + ) { + AlertView( + alertTitle: ProfileLocalization.LogoutAlert.title, + alertMessage: ProfileLocalization.LogoutAlert.text, + positiveAction: CoreLocalization.Alert.accept, + onCloseTapped: { + viewModel.router.dismiss(animated: true) + }, + okTapped: { + viewModel.router.dismiss(animated: true) + Task { + await viewModel.logOut() + } + }, + type: .logOut + ) + } + }, label: { + HStack { + Text(ProfileLocalization.logout) + Spacer() + Image(systemName: "rectangle.portrait.and.arrow.right") + } + }) + .accessibilityElement(children: .ignore) + .accessibilityLabel(ProfileLocalization.logout) + .accessibilityIdentifier("logout_button") + } + .foregroundColor(Theme.Colors.alert) + .cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground, strokeColor: .clear) + .padding(.top, 24) + .padding(.bottom, 60) } } @@ -127,8 +217,11 @@ struct SettingsView_Previews: PreviewProvider { let router = ProfileRouterMock() let vm = SettingsViewModel( interactor: ProfileInteractor.mock, + downloadManager: DownloadManagerMock(), router: router, - analytics: CoreAnalyticsMock() + analytics: ProfileAnalyticsMock(), + coreAnalytics: CoreAnalyticsMock(), + config: ConfigMock() ) SettingsView(viewModel: vm) diff --git a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift index 499623a89..e31e09eea 100644 --- a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift +++ b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift @@ -8,6 +8,7 @@ import Foundation import Core import SwiftUI +import Combine public class SettingsViewModel: ObservableObject { @@ -40,6 +41,16 @@ public class SettingsViewModel: ObservableObject { ] .enumerated() ) + + enum VersionState { + case actual + case updateNeeded + case updateRequired + } + + @Published var versionState: VersionState = .actual + @Published var currentVersion: String = "" + @Published var latestVersion: String = "" var errorMessage: String? { didSet { @@ -50,26 +61,122 @@ public class SettingsViewModel: ObservableObject { } @Published private(set) var userSettings: UserSettings + + private var cancellables = Set() private let interactor: ProfileInteractorProtocol + private let downloadManager: DownloadManagerProtocol let router: ProfileRouter - let analytics: CoreAnalytics + let analytics: ProfileAnalytics + let coreAnalytics: CoreAnalytics + let config: ConfigProtocol - public init(interactor: ProfileInteractorProtocol, router: ProfileRouter, analytics: CoreAnalytics) { + public init( + interactor: ProfileInteractorProtocol, + downloadManager: DownloadManagerProtocol, + router: ProfileRouter, + analytics: ProfileAnalytics, + coreAnalytics: CoreAnalytics, + config: ConfigProtocol + ) { self.interactor = interactor + self.downloadManager = downloadManager self.router = router self.analytics = analytics + self.coreAnalytics = coreAnalytics + self.config = config let userSettings = interactor.getSettings() self.userSettings = userSettings self.wifiOnly = userSettings.wifiOnly self.selectedQuality = userSettings.streamingQuality + generateVersionState() + } + + func generateVersionState() { + guard let info = Bundle.main.infoDictionary else { return } + guard let currentVersion = info["CFBundleShortVersionString"] as? String else { return } + self.currentVersion = currentVersion + NotificationCenter.default.publisher(for: .onActualVersionReceived) + .sink { [weak self] notification in + guard let latestVersion = notification.object as? String else { return } + DispatchQueue.main.async { [weak self] in + self?.latestVersion = latestVersion + + if latestVersion != currentVersion { + self?.versionState = .updateNeeded + } + } + }.store(in: &cancellables) + } + + func contactSupport() -> URL? { + let osVersion = UIDevice.current.systemVersion + let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" + let deviceModel = UIDevice.current.model + let feedbackDetails = "OS version: \(osVersion)\nApp version: \(appVersion)\nDevice model: \(deviceModel)" + + let recipientAddress = config.feedbackEmail + let emailSubject = "Feedback" + let emailBody = "\n\n\(feedbackDetails)\n".addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)! + let emailURL = URL(string: "mailto:\(recipientAddress)?subject=\(emailSubject)&body=\(emailBody)") + return emailURL } func update(downloadQuality: DownloadQuality) { self.userSettings.downloadQuality = downloadQuality interactor.saveSettings(userSettings) } + + func openAppStore() { + guard let appStoreURL = URL(string: config.appStoreLink) else { return } + UIApplication.shared.open(appStoreURL) + } + + @MainActor + func logOut() async { + try? await interactor.logOut() + try? await downloadManager.cancelAllDownloading() + router.showStartupScreen() + analytics.userLogout(force: false) + } + + func trackProfileVideoSettingsClicked() { + analytics.profileVideoSettingsClicked() + } + + func trackEmailSupportClicked() { + analytics.emailSupportClicked() + } + + func trackCookiePolicyClicked() { + analytics.cookiePolicyClicked() + } + + func trackTOSClicked() { + analytics.tosClicked() + } + + func trackFAQClicked() { + analytics.faqClicked() + } + + func trackDataSellClicked() { + analytics.dataSellClicked() + } + + func trackPrivacyPolicyClicked() { + analytics.privacyPolicyClicked() + } + + func trackProfileEditClicked() { + analytics.profileEditClicked() + } + + func trackLogoutClickedClicked() { + analytics.profileEvent(.userLogoutClicked, biValue: .userLogoutClicked) + } + } public extension StreamingQuality { diff --git a/Profile/Profile/Presentation/Settings/VideoQualityView.swift b/Profile/Profile/Presentation/Settings/VideoQualityView.swift index b3decab29..a52565c19 100644 --- a/Profile/Profile/Presentation/Settings/VideoQualityView.swift +++ b/Profile/Profile/Presentation/Settings/VideoQualityView.swift @@ -14,6 +14,7 @@ public struct VideoQualityView: View { @ObservedObject private var viewModel: SettingsViewModel + @Environment (\.isHorizontal) private var isHorizontal public init(viewModel: SettingsViewModel) { self.viewModel = viewModel @@ -22,72 +23,102 @@ public struct VideoQualityView: View { public var body: some View { 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 { + VStack { + ThemeAssets.headerBackground.swiftUIImage + .resizable() + .edgesIgnoringSafeArea(.top) + } + .frame(maxWidth: .infinity, maxHeight: 200) + .accessibilityIdentifier("auth_bg_image") + + // MARK: - Page name + VStack(alignment: .center) { + ZStack { + HStack { + Text(ProfileLocalization.Settings.videoQualityTitle) + .titleSettings(color: Theme.Colors.loginNavigationText) + .accessibilityIdentifier("manage_account_text") + } + VStack { + BackNavigationButton( + color: Theme.Colors.loginNavigationText, + action: { + viewModel.router.back() + } + ) + .backViewStyle() + .padding(.leading, isHorizontal ? 48 : 0) + .accessibilityIdentifier("back_button") - 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() + }.frame(minWidth: 0, + maxWidth: .infinity, + alignment: .topLeading) + } + // MARK: - Page Body + ScrollView { + VStack(alignment: .leading, spacing: 24) { + if viewModel.isShowProgress { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 200) + .padding(.horizontal) + .accessibilityIdentifier("progress_bar") + } else { + ForEach(viewModel.quality, id: \.offset) { _, quality in + Button(action: { + viewModel.coreAnalytics.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(width: proxy.size.width) - } - .padding(.top, 8) - - // MARK: - Error Alert - if viewModel.showError { - VStack { - Spacer() - SnackBarView(message: viewModel.errorMessage) + }.frameLimit(width: proxy.size.width) + .padding(.horizontal, 24) + .padding(.top, 24) } - .transition(.move(edge: .bottom)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.errorMessage = nil + .roundedBackground(Theme.Colors.background) + + // 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(true) + .navigationBarBackButtonHidden(true) + .navigationTitle(ProfileLocalization.Settings.videoQualityTitle) + .ignoresSafeArea(.all, edges: .horizontal) + .background( + Theme.Colors.background + .ignoresSafeArea() + ) } } @@ -97,8 +128,11 @@ struct VideoQualityView_Previews: PreviewProvider { let router = ProfileRouterMock() let vm = SettingsViewModel( interactor: ProfileInteractor.mock, + downloadManager: DownloadManagerMock(), router: router, - analytics: CoreAnalyticsMock() + analytics: ProfileAnalyticsMock(), + coreAnalytics: CoreAnalyticsMock(), + config: ConfigMock() ) VideoQualityView(viewModel: vm) diff --git a/Profile/Profile/Presentation/Settings/VideoSettingsView.swift b/Profile/Profile/Presentation/Settings/VideoSettingsView.swift new file mode 100644 index 000000000..98e14ebb2 --- /dev/null +++ b/Profile/Profile/Presentation/Settings/VideoSettingsView.swift @@ -0,0 +1,148 @@ +// +// VideoSettingsView.swift +// Profile +// +// Created by  Stepanok Ivan on 09.04.2024. +// + +import SwiftUI +import Core +import Theme + +public struct VideoSettingsView: View { + + @ObservedObject + private var viewModel: SettingsViewModel + @Environment (\.isHorizontal) private var isHorizontal + + public init(viewModel: SettingsViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + GeometryReader { proxy in + ZStack(alignment: .top) { + VStack { + ThemeAssets.headerBackground.swiftUIImage + .resizable() + .edgesIgnoringSafeArea(.top) + } + .frame(maxWidth: .infinity, maxHeight: 200) + .accessibilityIdentifier("auth_bg_image") + + // MARK: - Page name + VStack(alignment: .center) { + ZStack { + HStack { + Text(ProfileLocalization.Settings.videoSettingsTitle) + .titleSettings(color: Theme.Colors.loginNavigationText) + .accessibilityIdentifier("manage_account_text") + } + VStack { + BackNavigationButton( + color: Theme.Colors.loginNavigationText, + action: { + viewModel.router.back() + } + ) + .backViewStyle() + .padding(.leading, isHorizontal ? 48 : 0) + .accessibilityIdentifier("back_button") + + }.frame(minWidth: 0, + maxWidth: .infinity, + alignment: .topLeading) + } + // MARK: - Page Body + ScrollView { + VStack(alignment: .leading, spacing: 24) { + // 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") + 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.coreAnalytics + ) + } label: { + SettingsCell( + title: CoreLocalization.Settings.videoDownloadQualityTitle, + description: viewModel.userSettings.downloadQuality.settingsDescription + ) + } + .accessibilityIdentifier("video_download_quality_button") + Image(systemName: "chevron.right") + .padding(.trailing, 12) + .frame(width: 10) + .accessibilityIdentifier("video_download_quality_image") + } + Divider() + } + .frameLimit(width: proxy.size.width) + .padding(.horizontal, 24) + .padding(.top, 24) + } + .roundedBackground(Theme.Colors.background) + } + } + } + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + .navigationTitle(ProfileLocalization.Settings.videoSettingsTitle) + .ignoresSafeArea(.all, edges: .horizontal) + .background( + Theme.Colors.background + .ignoresSafeArea() + ) + } +} + +#if DEBUG +struct VideoSettingsView_Previews: PreviewProvider { + static var previews: some View { + let router = ProfileRouterMock() + let vm = SettingsViewModel( + interactor: ProfileInteractor.mock, + downloadManager: DownloadManagerMock(), + router: router, + analytics: ProfileAnalyticsMock(), + coreAnalytics: CoreAnalyticsMock(), + config: ConfigMock() + ) + + VideoSettingsView(viewModel: vm) + .preferredColorScheme(.light) + .previewDisplayName("SettingsView Light") + .loadFonts() + } +} +#endif diff --git a/Profile/Profile/SwiftGen/Strings.swift b/Profile/Profile/SwiftGen/Strings.swift index d1adacf55..5c26b5557 100644 --- a/Profile/Profile/SwiftGen/Strings.swift +++ b/Profile/Profile/SwiftGen/Strings.swift @@ -10,6 +10,8 @@ import Foundation // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces public enum ProfileLocalization { + /// About Me + public static let about = ProfileLocalization.tr("Localizable", "ABOUT", fallback: "About Me") /// Bio: public static let bio = ProfileLocalization.tr("Localizable", "BIO", fallback: "Bio:") /// Contact support @@ -18,8 +20,8 @@ public enum ProfileLocalization { public static let cookiePolicy = ProfileLocalization.tr("Localizable", "COOKIE_POLICY", fallback: "Cookie policy") /// Do not sell my personal information public static let doNotSellInformation = ProfileLocalization.tr("Localizable", "DO_NOT_SELL_INFORMATION", fallback: "Do not sell my personal information") - /// Edit profile - public static let editProfile = ProfileLocalization.tr("Localizable", "EDIT_PROFILE", fallback: "Edit profile") + /// Edit Profile + public static let editProfile = ProfileLocalization.tr("Localizable", "EDIT_PROFILE", fallback: "Edit Profile") /// View FAQ public static let faqTitle = ProfileLocalization.tr("Localizable", "FAQ_TITLE", fallback: "View FAQ") /// full profile @@ -30,6 +32,8 @@ public enum ProfileLocalization { public static let limitedProfile = ProfileLocalization.tr("Localizable", "LIMITED_PROFILE", fallback: "limited profile") /// Log out public static let logout = ProfileLocalization.tr("Localizable", "LOGOUT", fallback: "Log out") + /// Manage Account + public static let manageAccount = ProfileLocalization.tr("Localizable", "MANAGE_ACCOUNT", fallback: "Manage Account") /// Privacy policy public static let privacy = ProfileLocalization.tr("Localizable", "PRIVACY", fallback: "Privacy policy") /// Settings @@ -64,8 +68,8 @@ public enum ProfileLocalization { public static let password = ProfileLocalization.tr("Localizable", "DELETE_ACCOUNT.PASSWORD", fallback: "Password") /// Enter password public static let passwordDescription = ProfileLocalization.tr("Localizable", "DELETE_ACCOUNT.PASSWORD_DESCRIPTION", fallback: "Enter password") - /// Delete account - public static let title = ProfileLocalization.tr("Localizable", "DELETE_ACCOUNT.TITLE", fallback: "Delete account") + /// Delete Account + public static let title = ProfileLocalization.tr("Localizable", "DELETE_ACCOUNT.TITLE", fallback: "Delete Account") /// delete your account? public static let wantToDelete = ProfileLocalization.tr("Localizable", "DELETE_ACCOUNT.WANT_TO_DELETE", fallback: "delete your account?") } @@ -76,8 +80,8 @@ public enum ProfileLocalization { public static let title = ProfileLocalization.tr("Localizable", "DELETE_ALERT.TITLE", fallback: "Warning!") } public enum Edit { - /// Delete account - public static let deleteAccount = ProfileLocalization.tr("Localizable", "EDIT.DELETE_ACCOUNT", fallback: "Delete account") + /// Delete Account + public static let deleteAccount = ProfileLocalization.tr("Localizable", "EDIT.DELETE_ACCOUNT", fallback: "Delete Account") /// A limited profile only shares your username and profile photo. public static let limitedProfileDescription = ProfileLocalization.tr("Localizable", "EDIT.LIMITED_PROFILE_DESCRIPTION", fallback: "A limited profile only shares your username and profile photo.") /// You must be over 13 years old to have a profile with full access to information. diff --git a/Profile/Profile/en.lproj/Localizable.strings b/Profile/Profile/en.lproj/Localizable.strings index df2a437f8..be69b026d 100644 --- a/Profile/Profile/en.lproj/Localizable.strings +++ b/Profile/Profile/en.lproj/Localizable.strings @@ -8,7 +8,8 @@ "TITLE" = "Profile"; "INFO" = "Profile info"; -"EDIT_PROFILE" = "Edit profile"; +"ABOUT" = "About Me"; +"EDIT_PROFILE" = "Edit Profile"; "YEAR_OF_BIRTH" = "Year of birth:"; "BIO" = "Bio:"; "SETTINGS" = "Settings"; @@ -20,6 +21,7 @@ "COOKIE_POLICY" = "Cookie policy"; "DO_NOT_SELL_INFORMATION" = "Do not sell my personal information"; "FAQ_TITLE" = "View FAQ"; +"MANAGE_ACCOUNT" = "Manage Account"; "LOGOUT" = "Log out"; "SWITCH_TO" = "Switch to"; @@ -37,7 +39,7 @@ "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."; -"EDIT.DELETE_ACCOUNT" = "Delete account"; +"EDIT.DELETE_ACCOUNT" = "Delete Account"; "EDIT.FIELDS.YEAR_OF_BIRTH" = "Year of birth"; "EDIT.FIELDS.LOCATION" = "Location"; @@ -49,7 +51,7 @@ "EDIT.BOTTOM_SHEET.REMOVE" = "Remove photo"; "EDIT.BOTTOM_SHEET.CANCEL" = "Cancel"; -"DELETE_ACCOUNT.TITLE" = "Delete account"; +"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, please enter your account password."; diff --git a/Profile/Profile/uk.lproj/Localizable.strings b/Profile/Profile/uk.lproj/Localizable.strings index f0e4d0503..dbc3c5379 100644 --- a/Profile/Profile/uk.lproj/Localizable.strings +++ b/Profile/Profile/uk.lproj/Localizable.strings @@ -8,6 +8,7 @@ "TITLE" = "Профіль"; "INFO" = "Дані профілю"; +"ABOUT" = "Про Meне"; "EDIT_PROFILE" = "Редагування"; "YEAR_OF_BIRTH" = "Рік народження:"; "BIO" = "Біо:"; @@ -24,6 +25,7 @@ "SWITCH_TO" = "Переключити на"; "FULL_PROFILE" = "повний профіль"; "LIMITED_PROFILE" = "обмежений профіль"; +"MANAGE_ACCOUNT" = "Налаштування Профілю"; "LOGOUT_ALERT.TITLE" = "Підтвердження виходу"; "LOGOUT_ALERT.TEXT" = "Ви впевнені, що бажаєте вийти?"; diff --git a/Profile/ProfileTests/Presentation/EditProfile/EditProfileViewModelTests.swift b/Profile/ProfileTests/Presentation/EditProfile/EditProfileViewModelTests.swift index 2980fb4a8..50f77a155 100644 --- a/Profile/ProfileTests/Presentation/EditProfile/EditProfileViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/EditProfile/EditProfileViewModelTests.swift @@ -28,7 +28,8 @@ final class EditProfileViewModelTests: XCTestCase { country: "UA", spokenLanguage: "UA", shortBiography: "Bio", - isFullProfile: false + isFullProfile: false, + email: "" ) Given(interactor, .getSpokenLanguages(willReturn: [])) @@ -65,7 +66,8 @@ final class EditProfileViewModelTests: XCTestCase { country: "UA", spokenLanguage: "UA", shortBiography: "Bio", - isFullProfile: false + isFullProfile: false, + email: "" ) Given(interactor, .getSpokenLanguages(willReturn: [])) @@ -102,7 +104,8 @@ final class EditProfileViewModelTests: XCTestCase { country: "UA", spokenLanguage: "UA", shortBiography: "Bio", - isFullProfile: false + isFullProfile: false, + email: "" ) Given(interactor, .getSpokenLanguages(willReturn: [])) @@ -134,7 +137,8 @@ final class EditProfileViewModelTests: XCTestCase { country: "UA", spokenLanguage: "UA", shortBiography: "Bio", - isFullProfile: true + isFullProfile: true, + email: "" ) Given(interactor, .getSpokenLanguages(willReturn: [])) @@ -166,7 +170,8 @@ final class EditProfileViewModelTests: XCTestCase { country: "UA", spokenLanguage: "UA", shortBiography: "Bio", - isFullProfile: true + isFullProfile: true, + email: "" ) Given(interactor, .getSpokenLanguages(willReturn: [])) @@ -198,7 +203,8 @@ final class EditProfileViewModelTests: XCTestCase { country: "UA", spokenLanguage: "UA", shortBiography: "Bio", - isFullProfile: true + isFullProfile: true, + email: "" ) Given(interactor, .getSpokenLanguages(willReturn: [])) @@ -230,7 +236,8 @@ final class EditProfileViewModelTests: XCTestCase { country: "UA", spokenLanguage: "UA", shortBiography: "Bio", - isFullProfile: true + isFullProfile: true, + email: "" ) Given(interactor, .getSpokenLanguages(willReturn: [])) @@ -262,7 +269,8 @@ final class EditProfileViewModelTests: XCTestCase { country: "UA", spokenLanguage: "UA", shortBiography: "Bio", - isFullProfile: true + isFullProfile: true, + email: "" ) Given(interactor, .getSpokenLanguages(willReturn: [])) @@ -294,7 +302,8 @@ final class EditProfileViewModelTests: XCTestCase { country: "UA", spokenLanguage: "UA", shortBiography: "Bio", - isFullProfile: true + isFullProfile: true, + email: "" ) Given(interactor, .getSpokenLanguages(willReturn: [])) @@ -330,7 +339,8 @@ final class EditProfileViewModelTests: XCTestCase { country: "UA", spokenLanguage: "UA", shortBiography: "Bio", - isFullProfile: true + isFullProfile: true, + email: "" ) Given(interactor, .getSpokenLanguages(willReturn: [])) @@ -366,7 +376,8 @@ final class EditProfileViewModelTests: XCTestCase { country: "UA", spokenLanguage: "UA", shortBiography: "Bio", - isFullProfile: true + isFullProfile: true, + email: "" ) Given(interactor, .getSpokenLanguages(willReturn: [])) @@ -401,7 +412,8 @@ final class EditProfileViewModelTests: XCTestCase { country: "UA", spokenLanguage: "UA", shortBiography: "Bio", - isFullProfile: true + isFullProfile: true, + email: "" ) Given(interactor, .getSpokenLanguages(willReturn: [])) @@ -436,7 +448,8 @@ final class EditProfileViewModelTests: XCTestCase { country: "UA", spokenLanguage: "UA", shortBiography: "Bio", - isFullProfile: true + isFullProfile: true, + email: "" ) Given(interactor, .getSpokenLanguages(willReturn: [])) @@ -484,7 +497,8 @@ final class EditProfileViewModelTests: XCTestCase { country: "UA", spokenLanguage: "UA", shortBiography: "Bio", - isFullProfile: true + isFullProfile: true, + email: "" ) Given(interactor, .getSpokenLanguages(willReturn: [])) @@ -527,7 +541,8 @@ final class EditProfileViewModelTests: XCTestCase { country: "UA", spokenLanguage: "UA", shortBiography: "Bio", - isFullProfile: true + isFullProfile: true, + email: "" ) Given(interactor, .getSpokenLanguages(willReturn: [])) @@ -583,7 +598,8 @@ final class EditProfileViewModelTests: XCTestCase { country: "UA", spokenLanguage: "UA", shortBiography: "Bio", - isFullProfile: true + isFullProfile: true, + email: "" ) Given(interactor, .getSpokenLanguages(willReturn: [])) @@ -637,7 +653,8 @@ final class EditProfileViewModelTests: XCTestCase { country: "UA", spokenLanguage: "UA", shortBiography: "Bio", - isFullProfile: true + isFullProfile: true, + email: "" ) Given(interactor, .getSpokenLanguages(willReturn: [])) @@ -675,7 +692,8 @@ final class EditProfileViewModelTests: XCTestCase { country: "UA", spokenLanguage: "UA", shortBiography: "Bio", - isFullProfile: true + isFullProfile: true, + email: "" ) Given(interactor, .getSpokenLanguages(willReturn: [])) @@ -707,7 +725,8 @@ final class EditProfileViewModelTests: XCTestCase { country: "UA", spokenLanguage: "UA", shortBiography: "Bio", - isFullProfile: false + isFullProfile: false, + email: "" ) Given(interactor, .getSpokenLanguages(willReturn: [])) @@ -738,7 +757,8 @@ final class EditProfileViewModelTests: XCTestCase { country: "UA", spokenLanguage: "UA", shortBiography: "Bio", - isFullProfile: true + isFullProfile: true, + email: "" ) let languages = [ @@ -775,7 +795,8 @@ final class EditProfileViewModelTests: XCTestCase { country: "UA", spokenLanguage: "UA", shortBiography: "Bio", - isFullProfile: false + isFullProfile: false, + email: "" ) Given(interactor, .getSpokenLanguages(willReturn: [])) @@ -806,7 +827,8 @@ final class EditProfileViewModelTests: XCTestCase { country: "UA", spokenLanguage: "UA", shortBiography: "Bio", - isFullProfile: false + isFullProfile: false, + email: "" ) Given(interactor, .getSpokenLanguages(willReturn: [])) diff --git a/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift b/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift index 40e56bbfa..44d3b96be 100644 --- a/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/Profile/ProfileViewModelTests.swift @@ -30,7 +30,8 @@ final class ProfileViewModelTests: XCTestCase { yearOfBirth: 2000, country: "Ua", shortBiography: "Bio", - isFullProfile: false + isFullProfile: false, + email: "" ) Given(interactor, .getUserProfile(username: .value("Steve"), willReturn: user)) @@ -92,7 +93,6 @@ final class ProfileViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() let viewModel = ProfileViewModel( interactor: interactor, - downloadManager: DownloadManagerMock(), router: router, analytics: analytics, config: ConfigMock(), @@ -107,7 +107,8 @@ final class ProfileViewModelTests: XCTestCase { yearOfBirth: 2000, country: "Ua", shortBiography: "Bio", - isFullProfile: false + isFullProfile: false, + email: "" ) Given(connectivity, .isInternetAvaliable(getter: true)) @@ -131,7 +132,6 @@ final class ProfileViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() let viewModel = ProfileViewModel( interactor: interactor, - downloadManager: DownloadManagerMock(), router: router, analytics: analytics, config: ConfigMock(), @@ -146,7 +146,8 @@ final class ProfileViewModelTests: XCTestCase { yearOfBirth: 2000, country: "Ua", shortBiography: "Bio", - isFullProfile: false + isFullProfile: false, + email: "" ) Given(connectivity, .isInternetAvaliable(getter: false)) @@ -169,7 +170,6 @@ final class ProfileViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() let viewModel = ProfileViewModel( interactor: interactor, - downloadManager: DownloadManagerMock(), router: router, analytics: analytics, config: ConfigMock(), @@ -184,7 +184,8 @@ final class ProfileViewModelTests: XCTestCase { yearOfBirth: 2000, country: "Ua", shortBiography: "Bio", - isFullProfile: false + isFullProfile: false, + email: "" ) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -209,7 +210,6 @@ final class ProfileViewModelTests: XCTestCase { let connectivity = ConnectivityProtocolMock() let viewModel = ProfileViewModel( interactor: interactor, - downloadManager: DownloadManagerMock(), router: router, analytics: analytics, config: ConfigMock(), @@ -227,121 +227,4 @@ final class ProfileViewModelTests: XCTestCase { XCTAssertFalse(viewModel.isShowProgress) XCTAssertTrue(viewModel.showError) } - - func testLogOutSuccess() async throws { - let interactor = ProfileInteractorProtocolMock() - let router = ProfileRouterMock() - let analytics = ProfileAnalyticsMock() - let connectivity = ConnectivityProtocolMock() - let viewModel = ProfileViewModel( - interactor: interactor, - downloadManager: DownloadManagerMock(), - router: router, - analytics: analytics, - config: ConfigMock(), - connectivity: connectivity - ) - - Given(connectivity, .isInternetAvaliable(getter: true)) - - await viewModel.logOut() - - Verify(router, .showStartupScreen()) - XCTAssertFalse(viewModel.showError) - } - - func testTrackProfileVideoSettingsClicked() { - let interactor = ProfileInteractorProtocolMock() - let router = ProfileRouterMock() - let analytics = ProfileAnalyticsMock() - let connectivity = ConnectivityProtocolMock() - let viewModel = ProfileViewModel( - interactor: interactor, - downloadManager: DownloadManagerMock(), - router: router, - analytics: analytics, - config: ConfigMock(), - connectivity: connectivity - ) - - viewModel.trackProfileVideoSettingsClicked() - - Verify(analytics, 1, .profileVideoSettingsClicked()) - } - - func testTrackEmailSupportClicked() { - let interactor = ProfileInteractorProtocolMock() - let router = ProfileRouterMock() - let analytics = ProfileAnalyticsMock() - let connectivity = ConnectivityProtocolMock() - let viewModel = ProfileViewModel( - interactor: interactor, - downloadManager: DownloadManagerMock(), - router: router, - analytics: analytics, - config: ConfigMock(), - connectivity: connectivity - ) - - viewModel.trackEmailSupportClicked() - - Verify(analytics, 1, .emailSupportClicked()) - } - - func testTrackCookiePolicyClicked() { - let interactor = ProfileInteractorProtocolMock() - let router = ProfileRouterMock() - let analytics = ProfileAnalyticsMock() - let connectivity = ConnectivityProtocolMock() - let viewModel = ProfileViewModel( - interactor: interactor, - downloadManager: DownloadManagerMock(), - router: router, - analytics: analytics, - config: ConfigMock(), - connectivity: connectivity - ) - - viewModel.trackCookiePolicyClicked() - - Verify(analytics, 1, .cookiePolicyClicked()) - } - - func testTrackPrivacyPolicyClicked() { - let interactor = ProfileInteractorProtocolMock() - let router = ProfileRouterMock() - let analytics = ProfileAnalyticsMock() - let connectivity = ConnectivityProtocolMock() - let viewModel = ProfileViewModel( - interactor: interactor, - downloadManager: DownloadManagerMock(), - router: router, - analytics: analytics, - config: ConfigMock(), - connectivity: connectivity - ) - - viewModel.trackPrivacyPolicyClicked() - - Verify(analytics, 1, .privacyPolicyClicked()) - } - - func testTrackProfileEditClicked() { - let interactor = ProfileInteractorProtocolMock() - let router = ProfileRouterMock() - let analytics = ProfileAnalyticsMock() - let connectivity = ConnectivityProtocolMock() - let viewModel = ProfileViewModel( - interactor: interactor, - downloadManager: DownloadManagerMock(), - router: router, - analytics: analytics, - config: ConfigMock(), - connectivity: connectivity - ) - - viewModel.trackProfileEditClicked() - - Verify(analytics, 1, .profileEditClicked()) - } } diff --git a/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift b/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift new file mode 100644 index 000000000..b9c77c6eb --- /dev/null +++ b/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift @@ -0,0 +1,203 @@ +// +// SettingsViewModelTests.swift +// ProfileTests +// +// Created by  Stepanok Ivan on 10.04.2024. +// + +import SwiftyMocky +import XCTest +@testable import Core +@testable import Profile +import Alamofire +import SwiftUI + +final class SettingsViewModelTests: XCTestCase { + + func testLogOutSuccess() async throws { + let interactor = ProfileInteractorProtocolMock() + let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() + let coreAnalytics = CoreAnalyticsMock() + + Given( + interactor, + .getSettings( + willReturn: UserSettings( + wifiOnly: true, + streamingQuality: .auto, + downloadQuality: .auto + ) + ) + ) + + let viewModel = SettingsViewModel( + interactor: interactor, + downloadManager: DownloadManagerMock(), + router: router, + analytics: analytics, + coreAnalytics: coreAnalytics, + config: ConfigMock() + ) + + await viewModel.logOut() + + Verify(router, .showStartupScreen()) + XCTAssertFalse(viewModel.showError) + } + + func testTrackProfileVideoSettingsClicked() { + let interactor = ProfileInteractorProtocolMock() + let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() + let coreAnalytics = CoreAnalyticsMock() + + Given( + interactor, + .getSettings( + willReturn: UserSettings( + wifiOnly: true, + streamingQuality: .auto, + downloadQuality: .auto + ) + ) + ) + + let viewModel = SettingsViewModel( + interactor: interactor, + downloadManager: DownloadManagerMock(), + router: router, + analytics: analytics, + coreAnalytics: coreAnalytics, + config: ConfigMock() + ) + + viewModel.trackProfileVideoSettingsClicked() + + Verify(analytics, 1, .profileVideoSettingsClicked()) + } + + func testTrackEmailSupportClicked() { + let interactor = ProfileInteractorProtocolMock() + let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() + let coreAnalytics = CoreAnalyticsMock() + + Given( + interactor, + .getSettings( + willReturn: UserSettings( + wifiOnly: true, + streamingQuality: .auto, + downloadQuality: .auto + ) + ) + ) + + let viewModel = SettingsViewModel( + interactor: interactor, + downloadManager: DownloadManagerMock(), + router: router, + analytics: analytics, + coreAnalytics: coreAnalytics, + config: ConfigMock() + ) + + viewModel.trackEmailSupportClicked() + + Verify(analytics, 1, .emailSupportClicked()) + } + + func testTrackCookiePolicyClicked() { + let interactor = ProfileInteractorProtocolMock() + let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() + let coreAnalytics = CoreAnalyticsMock() + + Given( + interactor, + .getSettings( + willReturn: UserSettings( + wifiOnly: true, + streamingQuality: .auto, + downloadQuality: .auto + ) + ) + ) + + let viewModel = SettingsViewModel( + interactor: interactor, + downloadManager: DownloadManagerMock(), + router: router, + analytics: analytics, + coreAnalytics: coreAnalytics, + config: ConfigMock() + ) + + viewModel.trackCookiePolicyClicked() + + Verify(analytics, 1, .cookiePolicyClicked()) + } + + func testTrackPrivacyPolicyClicked() { + let interactor = ProfileInteractorProtocolMock() + let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() + let coreAnalytics = CoreAnalyticsMock() + + Given( + interactor, + .getSettings( + willReturn: UserSettings( + wifiOnly: true, + streamingQuality: .auto, + downloadQuality: .auto + ) + ) + ) + + let viewModel = SettingsViewModel( + interactor: interactor, + downloadManager: DownloadManagerMock(), + router: router, + analytics: analytics, + coreAnalytics: coreAnalytics, + config: ConfigMock() + ) + + viewModel.trackPrivacyPolicyClicked() + + Verify(analytics, 1, .privacyPolicyClicked()) + } + + func testTrackProfileEditClicked() { + let interactor = ProfileInteractorProtocolMock() + let router = ProfileRouterMock() + let analytics = ProfileAnalyticsMock() + let coreAnalytics = CoreAnalyticsMock() + + Given( + interactor, + .getSettings( + willReturn: UserSettings( + wifiOnly: true, + streamingQuality: .auto, + downloadQuality: .auto + ) + ) + ) + + let viewModel = SettingsViewModel( + interactor: interactor, + downloadManager: DownloadManagerMock(), + router: router, + analytics: analytics, + coreAnalytics: coreAnalytics, + config: ConfigMock() + ) + + viewModel.trackProfileEditClicked() + + Verify(analytics, 1, .profileEditClicked()) + } +} diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 30ec58bb5..8f927c355 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -3045,6 +3045,18 @@ open class ProfileRouterMock: ProfileRouter, Mock { perform?() } + open func showVideoSettings() { + addInvocation(.m_showVideoSettings) + let perform = methodPerformValue(.m_showVideoSettings) as? () -> Void + perform?() + } + + open func showManageAccount() { + addInvocation(.m_showManageAccount) + let perform = methodPerformValue(.m_showManageAccount) as? () -> Void + perform?() + } + open func showVideoQualityView(viewModel: SettingsViewModel) { addInvocation(.m_showVideoQualityView__viewModel_viewModel(Parameter.value(`viewModel`))) let perform = methodPerformValue(.m_showVideoQualityView__viewModel_viewModel(Parameter.value(`viewModel`))) as? (SettingsViewModel) -> Void @@ -3163,6 +3175,8 @@ open class ProfileRouterMock: ProfileRouter, Mock { fileprivate enum MethodType { case m_showEditProfile__userModel_userModelavatar_avatarprofileDidEdit_profileDidEdit(Parameter, Parameter, Parameter<((UserProfile?, UIImage?)) -> Void>) case m_showSettings + case m_showVideoSettings + case m_showManageAccount case m_showVideoQualityView__viewModel_viewModel(Parameter) case m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics(Parameter, Parameter<((DownloadQuality) -> Void)?>, Parameter) case m_showDeleteProfileView @@ -3194,6 +3208,10 @@ open class ProfileRouterMock: ProfileRouter, Mock { case (.m_showSettings, .m_showSettings): return .match + case (.m_showVideoSettings, .m_showVideoSettings): return .match + + case (.m_showManageAccount, .m_showManageAccount): return .match + case (.m_showVideoQualityView__viewModel_viewModel(let lhsViewmodel), .m_showVideoQualityView__viewModel_viewModel(let rhsViewmodel)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsViewmodel, rhs: rhsViewmodel, with: matcher), lhsViewmodel, rhsViewmodel, "viewModel")) @@ -3304,6 +3322,8 @@ open class ProfileRouterMock: ProfileRouter, Mock { switch self { case let .m_showEditProfile__userModel_userModelavatar_avatarprofileDidEdit_profileDidEdit(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case .m_showSettings: return 0 + case .m_showVideoSettings: return 0 + case .m_showManageAccount: return 0 case let .m_showVideoQualityView__viewModel_viewModel(p0): return p0.intValue case let .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case .m_showDeleteProfileView: return 0 @@ -3329,6 +3349,8 @@ open class ProfileRouterMock: ProfileRouter, Mock { switch self { case .m_showEditProfile__userModel_userModelavatar_avatarprofileDidEdit_profileDidEdit: return ".showEditProfile(userModel:avatar:profileDidEdit:)" case .m_showSettings: return ".showSettings()" + case .m_showVideoSettings: return ".showVideoSettings()" + case .m_showManageAccount: return ".showManageAccount()" case .m_showVideoQualityView__viewModel_viewModel: return ".showVideoQualityView(viewModel:)" case .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics: return ".showVideoDownloadQualityView(downloadQuality:didSelect:analytics:)" case .m_showDeleteProfileView: return ".showDeleteProfileView()" @@ -3368,6 +3390,8 @@ 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 showVideoSettings() -> Verify { return Verify(method: .m_showVideoSettings)} + public static func showManageAccount() -> Verify { return Verify(method: .m_showManageAccount)} 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)?>, 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)} @@ -3399,6 +3423,12 @@ open class ProfileRouterMock: ProfileRouter, Mock { public static func showSettings(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showSettings, performs: perform) } + public static func showVideoSettings(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showVideoSettings, performs: perform) + } + public static func showManageAccount(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showManageAccount, performs: perform) + } public static func showVideoQualityView(viewModel: Parameter, perform: @escaping (SettingsViewModel) -> Void) -> Perform { return Perform(method: .m_showVideoQualityView__viewModel_viewModel(`viewModel`), performs: perform) } diff --git a/Theme/Theme/Assets.xcassets/Auth/Contents.json b/Theme/Theme/Assets.xcassets/Auth/Contents.json deleted file mode 100644 index 73c00596a..000000000 --- a/Theme/Theme/Assets.xcassets/Auth/Contents.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/Theme/Theme/Assets.xcassets/Auth/authBackground.imageset/Contents.json b/Theme/Theme/Assets.xcassets/headerBackground.imageset/Contents.json similarity index 100% rename from Theme/Theme/Assets.xcassets/Auth/authBackground.imageset/Contents.json rename to Theme/Theme/Assets.xcassets/headerBackground.imageset/Contents.json diff --git a/Theme/Theme/Assets.xcassets/Auth/authBackground.imageset/Rectangle-2.png b/Theme/Theme/Assets.xcassets/headerBackground.imageset/Rectangle-2.png similarity index 100% rename from Theme/Theme/Assets.xcassets/Auth/authBackground.imageset/Rectangle-2.png rename to Theme/Theme/Assets.xcassets/headerBackground.imageset/Rectangle-2.png diff --git a/Theme/Theme/Assets.xcassets/Auth/authBackground.imageset/Rectangle.png b/Theme/Theme/Assets.xcassets/headerBackground.imageset/Rectangle.png similarity index 100% rename from Theme/Theme/Assets.xcassets/Auth/authBackground.imageset/Rectangle.png rename to Theme/Theme/Assets.xcassets/headerBackground.imageset/Rectangle.png diff --git a/Theme/Theme/SwiftGen/ThemeAssets.swift b/Theme/Theme/SwiftGen/ThemeAssets.swift index 733a02635..b2573c7f0 100644 --- a/Theme/Theme/SwiftGen/ThemeAssets.swift +++ b/Theme/Theme/SwiftGen/ThemeAssets.swift @@ -24,7 +24,6 @@ public typealias AssetImageTypeAlias = ImageAsset.Image // swiftlint:disable identifier_name line_length nesting type_body_length type_name 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") @@ -81,6 +80,7 @@ public enum ThemeAssets { public static let warningText = ColorAsset(name: "warningText") public static let white = ColorAsset(name: "white") public static let appLogo = ImageAsset(name: "appLogo") + public static let headerBackground = ImageAsset(name: "headerBackground") } // swiftlint:enable identifier_name line_length nesting type_body_length type_name From 2f99d755c7ab7d0ac37e29924e2073de3199f626 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Tue, 7 May 2024 16:47:14 +0200 Subject: [PATCH 02/55] chore: added custom back button for downloads view --- .../Downloads/DownloadsView.swift | 28 ++++++++++++++++++- .../Downloads/DownloadsViewModel.swift | 4 +++ .../Outline/CourseOutlineView.swift | 2 +- OpenEdX/Router.swift | 7 ++++- 4 files changed, 38 insertions(+), 3 deletions(-) diff --git a/Course/Course/Presentation/Downloads/DownloadsView.swift b/Course/Course/Presentation/Downloads/DownloadsView.swift index 3c648ead4..791791204 100644 --- a/Course/Course/Presentation/Downloads/DownloadsView.swift +++ b/Course/Course/Presentation/Downloads/DownloadsView.swift @@ -15,12 +15,14 @@ public struct DownloadsView: View { // MARK: - Properties @Environment(\.dismiss) private var dismiss + @Environment (\.isHorizontal) private var isHorizontal @StateObject private var viewModel: DownloadsViewModel var isSheet: Bool = true public init( isSheet: Bool = true, + router: CourseRouter, courseId: String? = nil, downloads: [DownloadDataTask] = [], manager: DownloadManagerProtocol @@ -28,6 +30,7 @@ public struct DownloadsView: View { self.isSheet = isSheet self._viewModel = .init( wrappedValue: .init( + router: router, courseId: courseId, downloads: downloads, manager: manager @@ -38,13 +41,36 @@ public struct DownloadsView: View { // MARK: - Body public var body: some View { - ZStack { + ZStack(alignment: .top) { Theme.Colors.background .ignoresSafeArea() + if !isSheet { + HStack { + Text(CourseLocalization.Download.downloads) + .titleSettings(color: Theme.Colors.textPrimary) + .accessibilityIdentifier("downloads_text") + } + .padding(.top, isHorizontal ? 10 : 0) + VStack { + BackNavigationButton( + color: Theme.Colors.accentColor, + action: { + viewModel.router.back() + } + ) + .backViewStyle() + .padding(.leading, 8) + + } + .frame(minWidth: 0, maxWidth: .infinity, alignment: .topLeading) + .padding(.top, isHorizontal ? 23 : 13) + + } content .sheetNavigation(isSheet: isSheet) { dismiss() } + .padding(.top, isSheet ? 0 : 40) } } diff --git a/Course/Course/Presentation/Downloads/DownloadsViewModel.swift b/Course/Course/Presentation/Downloads/DownloadsViewModel.swift index 78c063778..709ecc402 100644 --- a/Course/Course/Presentation/Downloads/DownloadsViewModel.swift +++ b/Course/Course/Presentation/Downloads/DownloadsViewModel.swift @@ -15,15 +15,19 @@ final class DownloadsViewModel: ObservableObject { @Published private(set) var downloads: [DownloadDataTask] = [] private let courseId: String? + + let router: CourseRouter private let manager: DownloadManagerProtocol private var cancellables = Set() init( + router: CourseRouter, courseId: String? = nil, downloads: [DownloadDataTask] = [], manager: DownloadManagerProtocol ) { + self.router = router self.courseId = courseId self.manager = manager self.downloads = downloads diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 18575dc7a..90cf6e1d9 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -216,7 +216,7 @@ public struct CourseOutlineView: View { .ignoresSafeArea() ) .sheet(isPresented: $showingDownloads) { - DownloadsView(manager: viewModel.manager) + DownloadsView(router: viewModel.router, manager: viewModel.manager) } .sheet(isPresented: $showingVideoDownloadQuality) { viewModel.storage.userSettings.map { diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 6c96146b3..347d721ef 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -537,7 +537,12 @@ public class Router: AuthorizationRouter, downloads: [DownloadDataTask], manager: DownloadManagerProtocol ) { - let downloadsView = DownloadsView(isSheet: false, downloads: downloads, manager: manager) + let downloadsView = DownloadsView( + isSheet: false, + router: Container.shared.resolve(CourseRouter.self)!, + downloads: downloads, + manager: manager + ) let controller = UIHostingController(rootView: downloadsView) navigationController.pushViewController(controller, animated: true) } From bd9f081729b2c254e2d82cb698a2677f828609b8 Mon Sep 17 00:00:00 2001 From: Shafqat Muneer Date: Tue, 7 May 2024 21:08:24 +0500 Subject: [PATCH 03/55] feat: Alert for outdated course calendars on the course home screen --- Core/Core/Extensions/Notification.swift | 1 + .../Container/CourseContainerView.swift | 41 ++++++++++++++++++- .../Container/CourseContainerViewModel.swift | 1 + .../Presentation/Dates/CourseDatesView.swift | 3 +- .../Dates/CourseDatesViewModel.swift | 12 ++++++ Course/Course/Views/DatesSuccessView.swift | 7 +--- OpenEdX/Router.swift | 8 ++++ 7 files changed, 64 insertions(+), 9 deletions(-) diff --git a/Core/Core/Extensions/Notification.swift b/Core/Core/Extensions/Notification.swift index ba9dfe70c..9f792fb2a 100644 --- a/Core/Core/Extensions/Notification.swift +++ b/Core/Core/Extensions/Notification.swift @@ -17,4 +17,5 @@ public extension Notification.Name { static let onBlockCompletion = Notification.Name.init("onBlockCompletion") static let shiftCourseDates = Notification.Name("shiftCourseDates") static let profileUpdated = Notification.Name("profileUpdated") + static let getCourseDates = Notification.Name("getCourseDates") } diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 322b37564..d79f12034 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -15,6 +15,8 @@ public struct CourseContainerView: View { @ObservedObject public var viewModel: CourseContainerViewModel + @ObservedObject + public var courseDatesViewModel: CourseDatesViewModel @State private var isAnimatingForTap: Bool = false public var courseID: String private var title: String @@ -39,6 +41,7 @@ public struct CourseContainerView: View { public init( viewModel: CourseContainerViewModel, + courseDatesViewModel: CourseDatesViewModel, courseID: String, title: String ) { @@ -55,6 +58,7 @@ public struct CourseContainerView: View { } self.courseID = courseID self.title = title + self.courseDatesViewModel = courseDatesViewModel } public var body: some View { @@ -112,8 +116,32 @@ public struct CourseContainerView: View { } } } + + switch courseDatesViewModel.eventState { + case .removedCalendar: + showDatesSuccessView( + title: CourseLocalization.CourseDates.calendarEvents, + message: CourseLocalization.CourseDates.calendarEventsRemoved + ) + case .updatedCalendar: + showDatesSuccessView( + title: CourseLocalization.CourseDates.calendarEvents, + message: CourseLocalization.CourseDates.calendarEventsUpdated + ) + default: + EmptyView() + } } + private func showDatesSuccessView(title: String, message: String) -> some View { + return DatesSuccessView( + title: title, + message: message + ) { + courseDatesViewModel.resetEventState() + } + } + private func backButton(containerWidth: CGFloat) -> some View { ZStack(alignment: .topLeading) { if !collapsed { @@ -184,8 +212,7 @@ public struct CourseContainerView: View { courseID: courseID, coordinate: $coordinate, collapsed: $collapsed, - viewModel: Container.shared.resolve(CourseDatesViewModel.self, - arguments: courseID, title)! + viewModel: courseDatesViewModel ) .tabItem { tab.image @@ -313,6 +340,16 @@ struct CourseScreensView_Previews: PreviewProvider { enrollmentEnd: nil, coreAnalytics: CoreAnalyticsMock() ), + courseDatesViewModel: CourseDatesViewModel( + interactor: CourseInteractor.mock, + router: CourseRouterMock(), + cssInjector: CSSInjectorMock(), + connectivity: Connectivity(), + config: ConfigMock(), + courseID: "1", + courseName: "a", + analytics: CourseAnalyticsMock() + ), courseID: "", title: "Title of Course") } } diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index 82572b60e..e8ce69707 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -140,6 +140,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { do { if isInternetAvaliable { courseStructure = try await interactor.getCourseBlocks(courseID: courseID) + NotificationCenter.default.post(name: .getCourseDates, object: courseID) isShowProgress = false isShowRefresh = false if let courseStructure { diff --git a/Course/Course/Presentation/Dates/CourseDatesView.swift b/Course/Course/Presentation/Dates/CourseDatesView.swift index 92dc86a6c..d4a58dfd7 100644 --- a/Course/Course/Presentation/Dates/CourseDatesView.swift +++ b/Course/Course/Presentation/Dates/CourseDatesView.swift @@ -114,8 +114,7 @@ public struct CourseDatesView: View { } else { return DatesSuccessView( title: title, - message: message, - selectedTab: .dates + message: message ) { viewModel.resetEventState() } diff --git a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift index 2effb2678..8ee011025 100644 --- a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift +++ b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift @@ -195,6 +195,18 @@ extension CourseDatesViewModel { selector: #selector(handleShiftDueDates), name: .shiftCourseDates, object: nil ) + + NotificationCenter.default.addObserver( + self, + selector: #selector(getCourseDates), + name: .getCourseDates, object: nil + ) + } + + @objc private func getCourseDates(_ notification: Notification) { + Task { + await getCourseDates(courseID: courseID) + } } @objc private func handleShiftDueDates(_ notification: Notification) { diff --git a/Course/Course/Views/DatesSuccessView.swift b/Course/Course/Views/DatesSuccessView.swift index 3adfe607c..2b9040683 100644 --- a/Course/Course/Views/DatesSuccessView.swift +++ b/Course/Course/Views/DatesSuccessView.swift @@ -18,7 +18,7 @@ public struct DatesSuccessView: View { private var title: String private var message: String - var selectedTab: Tab + var selectedTab: Tab? var courseDatesViewModel: CourseDatesViewModel? var courseContainerViewModel: CourseContainerViewModel? var action: () -> Void = {} @@ -29,12 +29,10 @@ public struct DatesSuccessView: View { init ( title: String, message: String, - selectedTab: Tab, dismissAction: @escaping () -> Void ) { self.title = title self.message = message - self.selectedTab = selectedTab self.dismissAction = dismissAction } @@ -149,8 +147,7 @@ struct DatesSuccessView_Previews: PreviewProvider { static var previews: some View { DatesSuccessView( title: CourseLocalization.CourseDates.toastSuccessTitle, - message: CourseLocalization.CourseDates.toastSuccessMessage, - selectedTab: .course + message: CourseLocalization.CourseDates.toastSuccessMessage ) {} } } diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 80fd75ad6..8e582a5f4 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -395,8 +395,16 @@ public class Router: AuthorizationRouter, enrollmentStart, enrollmentEnd )! + + let datesVm = Container.shared.resolve( + CourseDatesViewModel.self, + arguments: courseID, + title + )! + let screensView = CourseContainerView( viewModel: vm, + courseDatesViewModel: datesVm, courseID: courseID, title: title ) From 102e44c2af2b4225de8c329ef39bbc82a5906f5b Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Thu, 9 May 2024 10:40:34 +0500 Subject: [PATCH 04/55] chore: Sliding course menu theme enhancement --- .../ScrollSlidingTabBar.swift | 2 +- .../Contents.json | 38 +++++++++++++++++++ Theme/Theme/SwiftGen/ThemeAssets.swift | 1 + Theme/Theme/Theme.swift | 1 + 4 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 Theme/Theme/Assets.xcassets/Colors/SlidingTabBar/slidingSelectedTextColor.colorset/Contents.json diff --git a/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift index 5f09777a7..bd558d197 100644 --- a/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift +++ b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift @@ -100,7 +100,7 @@ extension ScrollSlidingTabBar { } .accentColor( isSelected(index: obj.offset) - ? Theme.Colors.white + ? Theme.Colors.slidingSelectedTextColor : Theme.Colors.slidingTextColor ) } diff --git a/Theme/Theme/Assets.xcassets/Colors/SlidingTabBar/slidingSelectedTextColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/SlidingTabBar/slidingSelectedTextColor.colorset/Contents.json new file mode 100644 index 000000000..22c4bb0a8 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/SlidingTabBar/slidingSelectedTextColor.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/SwiftGen/ThemeAssets.swift b/Theme/Theme/SwiftGen/ThemeAssets.swift index b2573c7f0..aa00c67f5 100644 --- a/Theme/Theme/SwiftGen/ThemeAssets.swift +++ b/Theme/Theme/SwiftGen/ThemeAssets.swift @@ -56,6 +56,7 @@ public enum ThemeAssets { public static let secondaryButtonBorderColor = ColorAsset(name: "SecondaryButtonBorderColor") public static let secondaryButtonTextColor = ColorAsset(name: "SecondaryButtonTextColor") public static let shadowColor = ColorAsset(name: "ShadowColor") + public static let slidingSelectedTextColor = ColorAsset(name: "slidingSelectedTextColor") public static let slidingStrokeColor = ColorAsset(name: "slidingStrokeColor") public static let slidingTextColor = ColorAsset(name: "slidingTextColor") public static let snackbarErrorColor = ColorAsset(name: "SnackbarErrorColor") diff --git a/Theme/Theme/Theme.swift b/Theme/Theme/Theme.swift index 0fad87eb9..c985b752b 100644 --- a/Theme/Theme/Theme.swift +++ b/Theme/Theme/Theme.swift @@ -65,6 +65,7 @@ public struct Theme { public private(set) static var infoColor = ThemeAssets.infoColor.swiftUIColor public private(set) static var irreversibleAlert = ThemeAssets.irreversibleAlert.swiftUIColor public private(set) static var slidingTextColor = ThemeAssets.slidingTextColor.swiftUIColor + public private(set) static var slidingSelectedTextColor = ThemeAssets.slidingSelectedTextColor.swiftUIColor public private(set) static var slidingStrokeColor = ThemeAssets.slidingStrokeColor.swiftUIColor public private(set) static var primaryHeaderColor = ThemeAssets.primaryHeaderColor.swiftUIColor public private(set) static var secondaryHeaderColor = ThemeAssets.secondaryHeaderColor.swiftUIColor From 5305a92e3d481b6293dc5204a30da2d0c6b80508 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta <127732735+volodymyr-chekyrta@users.noreply.github.com> Date: Thu, 9 May 2024 15:55:46 +0300 Subject: [PATCH 05/55] fix: discovery enabled by default (#433) --- Core/Core/Configuration/Config/DiscoveryConfig.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Core/Configuration/Config/DiscoveryConfig.swift b/Core/Core/Configuration/Config/DiscoveryConfig.swift index 884800441..893ea0ca9 100644 --- a/Core/Core/Configuration/Config/DiscoveryConfig.swift +++ b/Core/Core/Configuration/Config/DiscoveryConfig.swift @@ -40,7 +40,7 @@ public class DiscoveryConfig: NSObject { init(dictionary: [String: AnyObject]) { type = (dictionary[DiscoveryKeys.discoveryType] as? String).flatMap { DiscoveryConfigType(rawValue: $0) - } ?? .none + } ?? .native webview = DiscoveryWebviewConfig(dictionary: dictionary[DiscoveryKeys.webview] as? [String: AnyObject] ?? [:]) } From 9e18a29c8d94671638088254382d62aff9a53177 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta <127732735+volodymyr-chekyrta@users.noreply.github.com> Date: Thu, 9 May 2024 15:56:55 +0300 Subject: [PATCH 06/55] fix: build warnings (#432) --- .swiftlint.yml | 11 ++++++++- .../Presentation/AuthorizationAnalytics.swift | 8 +++---- Core/Core/Extensions/DateExtension.swift | 1 - Core/Core/Extensions/Dictionary+JSON.swift | 2 +- Core/Core/Extensions/String+JSON.swift | 2 +- Core/Core/Extensions/ViewExtension.swift | 2 +- Core/Core/Network/DownloadManager.swift | 2 ++ .../SocialAuth/MicrosoftAuthProvider.swift | 2 +- .../ThirdPartyMailClient.swift | 3 +++ .../View/Base/RefreshableScrollView.swift | 2 +- Core/Core/View/Base/UnitButtonView.swift | 3 ++- Core/Core/View/Base/Webview/WebView.swift | 4 ++-- Course/Course.xcodeproj/project.pbxproj | 16 ++++--------- .../Presentation/Dates/CourseDatesView.swift | 2 +- .../Outline/CourseOutlineView.swift | 2 +- .../Unit/CourseNavigationView.swift | 2 +- .../Presentation/Video/SubtittlesView.swift | 2 +- .../Video/YouTubeVideoPlayer.swift | 2 +- .../Data/Network/DiscussionRepository.swift | 4 ++-- .../Discussion/Domain/Model/UserThread.swift | 2 +- .../DiscussionSearchTopicsViewModel.swift | 2 +- .../AnalyticsManager/AnalyticsManager.swift | 10 +++++--- .../Listeners/BrazeListener.swift | 5 ++-- Podfile | 2 +- Podfile.lock | 12 +++++----- .../EditProfile/EditProfileViewModel.swift | 23 +++++++++++-------- Theme/Theme/Theme.swift | 2 ++ 27 files changed, 72 insertions(+), 58 deletions(-) diff --git a/.swiftlint.yml b/.swiftlint.yml index f6b96b50b..600160d85 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -24,8 +24,17 @@ excluded: # paths to ignore during linting. Takes precedence over `included`. - Discovery/DiscoveryTests - Discussion/DiscussionTests - Profile/ProfileTests + - WhatsNew/WhatsNewTests + - Theme/ThemeTests - vendor -# - Source/ExcludedFolder + - Core/Core/SwiftGen + - Authorization/Authorization/SwiftGen + - Course/Course/SwiftGen + - Discovery/Discovery/SwiftGen + - Dashboard/Dashboard/SwiftGen + - Profile/Profile/SwiftGen + - WhatsNew/WhatsNew/SwiftGen + - Theme/Theme/SwiftGen # - Source/ExcludedFile.swift # - Source/*/ExcludedFile.swift # Exclude files with a wildcard #analyzer_rules: # Rules run by `swiftlint analyze` (experimental) diff --git a/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift b/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift index b59ebd774..00cb384a5 100644 --- a/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift +++ b/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift @@ -22,10 +22,10 @@ public enum AuthMethod: Equatable { } public enum SocialAuthMethod: String { - case facebook = "facebook" - case google = "google" - case microsoft = "microsoft" - case apple = "apple" + case facebook + case google + case microsoft + case apple } //sourcery: AutoMockable diff --git a/Core/Core/Extensions/DateExtension.swift b/Core/Core/Extensions/DateExtension.swift index 8a57079f4..bbdb6834b 100644 --- a/Core/Core/Extensions/DateExtension.swift +++ b/Core/Core/Extensions/DateExtension.swift @@ -7,7 +7,6 @@ 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"] diff --git a/Core/Core/Extensions/Dictionary+JSON.swift b/Core/Core/Extensions/Dictionary+JSON.swift index 398fc3676..938cef881 100644 --- a/Core/Core/Extensions/Dictionary+JSON.swift +++ b/Core/Core/Extensions/Dictionary+JSON.swift @@ -8,7 +8,7 @@ import Foundation public extension Dictionary where Key == String, Value == String { - public func toJson() -> String? { + func toJson() -> String? { guard let jsonData = try? JSONSerialization.data(withJSONObject: self, options: []) else { return nil } diff --git a/Core/Core/Extensions/String+JSON.swift b/Core/Core/Extensions/String+JSON.swift index ab171369e..6d801a886 100644 --- a/Core/Core/Extensions/String+JSON.swift +++ b/Core/Core/Extensions/String+JSON.swift @@ -8,7 +8,7 @@ import Foundation public extension String { - public func jsonStringToDictionary() -> [String: Any]? { + func jsonStringToDictionary() -> [String: Any]? { guard let jsonData = self.data(using: .utf8) else { return nil } diff --git a/Core/Core/Extensions/ViewExtension.swift b/Core/Core/Extensions/ViewExtension.swift index 4d98df77d..71392ebd7 100644 --- a/Core/Core/Extensions/ViewExtension.swift +++ b/Core/Core/Extensions/ViewExtension.swift @@ -154,7 +154,7 @@ public extension View { .offset(y: 2) .foregroundColor(color) self - .offset(y: 2) + .offset(y: 2) } } diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index 2f967597a..15ec5fd10 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -551,6 +551,7 @@ public final class BackgroundTaskProvider { } // Mark - For testing and SwiftUI preview +// swiftlint:disable file_length #if DEBUG public class DownloadManagerMock: DownloadManagerProtocol { @@ -639,3 +640,4 @@ public class DownloadManagerMock: DownloadManagerProtocol { } #endif +// swiftlint:enable file_length diff --git a/Core/Core/Providers/SocialAuth/MicrosoftAuthProvider.swift b/Core/Core/Providers/SocialAuth/MicrosoftAuthProvider.swift index 16178b17c..2fd998579 100644 --- a/Core/Core/Providers/SocialAuth/MicrosoftAuthProvider.swift +++ b/Core/Core/Providers/SocialAuth/MicrosoftAuthProvider.swift @@ -49,7 +49,7 @@ public final class MicrosoftAuthProvider { continuation.resume( returning: .success( SocialAuthResponse( - name: account.accountClaims?["name"] as? String ?? "" , + name: account.accountClaims?["name"] as? String ?? "", email: account.accountClaims?["email"] as? String ?? "", token: result.accessToken ) diff --git a/Core/Core/View/Base/AppReview/ThirdPartyMailer/ThirdPartyMailClient.swift b/Core/Core/View/Base/AppReview/ThirdPartyMailer/ThirdPartyMailClient.swift index 76d48270e..5fdf14d34 100644 --- a/Core/Core/View/Base/AppReview/ThirdPartyMailer/ThirdPartyMailClient.swift +++ b/Core/Core/View/Base/AppReview/ThirdPartyMailer/ThirdPartyMailClient.swift @@ -5,6 +5,8 @@ // // Licensed under MIT License +// swiftlint:disable all + import SwiftUI /// A third-party mail client, offering a custom URL scheme. @@ -145,3 +147,4 @@ public extension ThirdPartyMailClient { } } } +// swiftlint:enable all diff --git a/Core/Core/View/Base/RefreshableScrollView.swift b/Core/Core/View/Base/RefreshableScrollView.swift index 0905bdba6..d09148528 100644 --- a/Core/Core/View/Base/RefreshableScrollView.swift +++ b/Core/Core/View/Base/RefreshableScrollView.swift @@ -282,7 +282,7 @@ public extension List { onRefresh: @escaping OnRefresh, @ViewBuilder progress: @escaping RefreshProgressBuilder) -> some View { - if #available(iOS 15.0, macOS 12.0, *) { + if #available(macOS 12.0, *) { self.refreshable { await withCheckedContinuation { cont in onRefresh { diff --git a/Core/Core/View/Base/UnitButtonView.swift b/Core/Core/View/Base/UnitButtonView.swift index 6e39ff997..d347cde97 100644 --- a/Core/Core/View/Base/UnitButtonView.swift +++ b/Core/Core/View/Base/UnitButtonView.swift @@ -199,7 +199,8 @@ public struct UnitButtonView: View { miterLimit: 1 )) .foregroundColor( - type == .continueLesson ? Theme.Colors.accentButtonColor + type == .continueLesson + ? Theme.Colors.accentButtonColor : Theme.Colors.secondaryButtonBorderColor ) ) diff --git a/Core/Core/View/Base/Webview/WebView.swift b/Core/Core/View/Base/Webview/WebView.swift index ce757b1c9..cb36ea255 100644 --- a/Core/Core/View/Base/Webview/WebView.swift +++ b/Core/Core/View/Base/Webview/WebView.swift @@ -302,8 +302,8 @@ extension WKWebView { extension Array where Element == WebviewInjection { func handle(message: WKScriptMessage) { - let messages = compactMap{ $0.messages } - .flatMap{ $0 } + 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/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index a118f173b..6ecf45e64 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -49,7 +49,6 @@ 02F0144F28F46474002E513D /* CourseContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F0144E28F46474002E513D /* CourseContainerView.swift */; }; 02F0145728F4A2FF002E513D /* CourseContainerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F0145628F4A2FF002E513D /* CourseContainerViewModel.swift */; }; 02F066E829DC71750073E13B /* SubtittlesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F066E729DC71750073E13B /* SubtittlesView.swift */; }; - 02F175372A4DAFD20019CD70 /* CourseAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F175362A4DAFD20019CD70 /* CourseAnalytics.swift */; }; 02F3BFDD29252E900051930C /* CourseRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F3BFDC29252E900051930C /* CourseRouter.swift */; }; 02F78AEB29E6BCA20038DE30 /* VideoPlayerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F78AEA29E6BCA20038DE30 /* VideoPlayerViewModelTests.swift */; }; 02F98A8128F8224200DE94C0 /* Discussion.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02F98A8028F8224200DE94C0 /* Discussion.framework */; }; @@ -67,6 +66,7 @@ 0766DFCC299AA7A600EBEF6A /* YouTubeVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0766DFCB299AA7A600EBEF6A /* YouTubeVideoPlayer.swift */; }; 0766DFCE299AB26D00EBEF6A /* EncodedVideoPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0766DFCD299AB26D00EBEF6A /* EncodedVideoPlayer.swift */; }; 0766DFD0299AB29000EBEF6A /* PlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0766DFCF299AB29000EBEF6A /* PlayerViewController.swift */; }; + 07DE59862BECB868001CBFBC /* CourseAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07DE59852BECB868001CBFBC /* CourseAnalytics.swift */; }; 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 */; }; @@ -148,7 +148,6 @@ 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; 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; }; @@ -166,6 +165,7 @@ 0766DFCB299AA7A600EBEF6A /* YouTubeVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeVideoPlayer.swift; sourceTree = ""; }; 0766DFCD299AB26D00EBEF6A /* EncodedVideoPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncodedVideoPlayer.swift; sourceTree = ""; }; 0766DFCF299AB29000EBEF6A /* PlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViewController.swift; sourceTree = ""; }; + 07DE59852BECB868001CBFBC /* CourseAnalytics.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CourseAnalytics.swift; sourceTree = ""; }; 2A444220A08C5035164B071F /* Pods-App-Course-CourseTests.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Course-CourseTests.releasedev.xcconfig"; path = "Target Support Files/Pods-App-Course-CourseTests/Pods-App-Course-CourseTests.releasedev.xcconfig"; sourceTree = ""; }; 3A55620C6018088BFF77F9AE /* Pods-App-CourseDetails.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-CourseDetails.debug.xcconfig"; path = "Target Support Files/Pods-App-CourseDetails/Pods-App-CourseDetails.debug.xcconfig"; sourceTree = ""; }; 3D506212980347A9D5A70E20 /* Pods-App-Course.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Course.debugstage.xcconfig"; path = "Target Support Files/Pods-App-Course/Pods-App-Course.debugstage.xcconfig"; sourceTree = ""; }; @@ -294,7 +294,6 @@ 0289F8F028E1C3510064F8F3 /* Course */ = { isa = PBXGroup; children = ( - 979A6AB92BC3FFF8001B0DE3 /* Analytics */, 02B6B3AD28E1C47100232911 /* SwiftGen */, 02B6B3B828E1D12900232911 /* Data */, 02B6B3B528E1D10700232911 /* Domain */, @@ -397,6 +396,7 @@ BAC0E0DC2B32F0EA006B68A9 /* Downloads */, BAD9CA482B2C88D500DE790A /* Subviews */, 02F3BFDC29252E900051930C /* CourseRouter.swift */, + 07DE59852BECB868001CBFBC /* CourseAnalytics.swift */, ); path = Presentation; sourceTree = ""; @@ -508,14 +508,6 @@ path = Mock; sourceTree = ""; }; - 979A6AB92BC3FFF8001B0DE3 /* Analytics */ = { - isa = PBXGroup; - children = ( - 02F175362A4DAFD20019CD70 /* CourseAnalytics.swift */, - ); - path = Analytics; - sourceTree = ""; - }; 97CA95212B875EA200A9EDEA /* Views */ = { isa = PBXGroup; children = ( @@ -892,6 +884,7 @@ BAC0E0DB2B32F0AE006B68A9 /* CourseVideoDownloadBarViewModel.swift in Sources */, DB7D6EAE2ADFCB4A0036BB13 /* CourseDatesViewModel.swift in Sources */, 02F066E829DC71750073E13B /* SubtittlesView.swift in Sources */, + 07DE59862BECB868001CBFBC /* CourseAnalytics.swift in Sources */, 022C64E229ADEB83000F532B /* CourseUpdate.swift in Sources */, BA58CF642B471363005B102E /* VideoDownloadQualityContainerView.swift in Sources */, BA58CF5D2B3D804D005B102E /* CourseStorage.swift in Sources */, @@ -908,7 +901,6 @@ 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 */, ); diff --git a/Course/Course/Presentation/Dates/CourseDatesView.swift b/Course/Course/Presentation/Dates/CourseDatesView.swift index d4a58dfd7..d63722f3f 100644 --- a/Course/Course/Presentation/Dates/CourseDatesView.swift +++ b/Course/Course/Presentation/Dates/CourseDatesView.swift @@ -509,7 +509,7 @@ struct CourseDatesView_Previews: PreviewProvider { CourseDatesView( courseID: "", coordinate: .constant(0), - collapsed: .constant(false), + collapsed: .constant(false), viewModel: viewModel) } } diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 741b76bdf..7a72f6fa0 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -364,7 +364,7 @@ struct CourseOutlineView_Previews: PreviewProvider { title: "Course title", courseID: "", isVideo: false, - selection: $selection, + selection: $selection, coordinate: .constant(0), collapsed: .constant(false), dateTabIndex: 2 diff --git a/Course/Course/Presentation/Unit/CourseNavigationView.swift b/Course/Course/Presentation/Unit/CourseNavigationView.swift index 334b3cf08..76788d26f 100644 --- a/Course/Course/Presentation/Unit/CourseNavigationView.swift +++ b/Course/Course/Presentation/Unit/CourseNavigationView.swift @@ -156,7 +156,7 @@ struct CourseNavigationView_Previews: PreviewProvider { chapterIndex: 1, sequentialIndex: 1, verticalIndex: 1, - interactor: CourseInteractor.mock, + interactor: CourseInteractor.mock, config: ConfigMock(), router: CourseRouterMock(), analytics: CourseAnalyticsMock(), diff --git a/Course/Course/Presentation/Video/SubtittlesView.swift b/Course/Course/Presentation/Video/SubtittlesView.swift index fb38221cc..f2a1bf81d 100644 --- a/Course/Course/Presentation/Video/SubtittlesView.swift +++ b/Course/Course/Presentation/Video/SubtittlesView.swift @@ -124,7 +124,7 @@ struct SubtittlesView_Previews: PreviewProvider { blockID: "", courseID: "", languages: [], interactor: CourseInteractor(repository: CourseRepositoryMock()), - router: CourseRouterMock(), + router: CourseRouterMock(), appStorage: CoreStorageMock(), connectivity: Connectivity() ), scrollTo: {_ in } diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift index 631e11a7c..1ee73812a 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift @@ -59,7 +59,7 @@ public struct YouTubeVideoPlayer: View { SubtittlesView( languages: viewModel.languages, currentTime: $viewModel.currentTime, - viewModel: viewModel, + viewModel: viewModel, scrollTo: { date in viewModel.youtubePlayer.seek( to: Measurement(value: date.secondsSinceMidnight(), unit: UnitDuration.seconds), diff --git a/Discussion/Discussion/Data/Network/DiscussionRepository.swift b/Discussion/Discussion/Data/Network/DiscussionRepository.swift index cc56f88c7..9ece98508 100644 --- a/Discussion/Discussion/Data/Network/DiscussionRepository.swift +++ b/Discussion/Discussion/Data/Network/DiscussionRepository.swift @@ -210,8 +210,8 @@ public class DiscussionRepository: DiscussionRepositoryProtocol { } // Mark - For testing and SwiftUI preview -#if DEBUG // swiftlint:disable all +#if DEBUG public class DiscussionRepositoryMock: DiscussionRepositoryProtocol { public func getCourseDiscussionInfo(courseID: String) async throws -> DiscussionInfo { @@ -515,5 +515,5 @@ public class DiscussionRepositoryMock: DiscussionRepositoryProtocol { return stringJSON } } -// swiftlint:enable all #endif +// swiftlint:enable all diff --git a/Discussion/Discussion/Domain/Model/UserThread.swift b/Discussion/Discussion/Domain/Model/UserThread.swift index 81e8dcf0c..4b33833a5 100644 --- a/Discussion/Discussion/Domain/Model/UserThread.swift +++ b/Discussion/Discussion/Domain/Model/UserThread.swift @@ -49,7 +49,7 @@ public struct UserThread { renderedBody: String, voted: Bool, voteCount: Int, - courseID: String, + courseID: String, type: PostType, title: String, pinned: Bool, diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift index 9b87fee37..95afa250b 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift @@ -161,7 +161,7 @@ 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/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift index aca6aff6b..8208d1047 100644 --- a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift +++ b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift @@ -21,6 +21,7 @@ protocol AnalyticsService { func logEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) } +// swiftlint:disable type_body_length file_length class AnalyticsManager: AuthorizationAnalytics, MainScreenAnalytics, DiscoveryAnalytics, @@ -30,6 +31,7 @@ class AnalyticsManager: AuthorizationAnalytics, DiscussionAnalytics, CoreAnalytics, WhatsNewAnalytics { + private var services: [AnalyticsService] = [] // Init Analytics Manager @@ -223,7 +225,7 @@ class AnalyticsManager: AuthorizationAnalytics, EventParamKey.name: EventBIValue.profileDeleteAccountClicked.rawValue, EventParamKey.category: EventCategory.profile ] - logEvent(.profileDeleteAccountClicked) + logEvent(.profileDeleteAccountClicked, parameters: parameters) } public func profileVideoSettingsClicked() { @@ -329,9 +331,10 @@ class AnalyticsManager: AuthorizationAnalytics, public func userLogout(force: Bool) { let parameters = [ EventParamKey.name: EventBIValue.userLogout.rawValue, - EventParamKey.category: EventCategory.profile + EventParamKey.category: EventCategory.profile, + EventParamKey.force: "\(force)" ] - logEvent(.userLogout, parameters: [EventParamKey.force: force]) + logEvent(.userLogout, parameters: parameters) } // MARK: Course @@ -779,3 +782,4 @@ class AnalyticsManager: AuthorizationAnalytics, logEvent(.whatnewClose, parameters: parameters) } } +// swiftlint:enable type_body_length file_length diff --git a/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift b/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift index 7c2bde74a..5d049e02e 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift @@ -10,8 +10,7 @@ import Foundation class BrazeListener: PushNotificationsListener { func shouldListenNotification(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 + let data = userinfo["ab"] as? [String: Any] + return userinfo.count > 0 && data != nil } } diff --git a/Podfile b/Podfile index b644e0124..992beae02 100644 --- a/Podfile +++ b/Podfile @@ -4,7 +4,7 @@ use_frameworks! :linkage => :static abstract_target "App" do #Code style - pod 'SwiftLint', '~> 0.5' + pod 'SwiftLint', '~> 0.54.0' #CodeGen for resources pod 'SwiftGen', '~> 6.6' diff --git a/Podfile.lock b/Podfile.lock index afb31e61a..ac4754acd 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -5,8 +5,8 @@ PODS: - Sourcery (1.8.0): - Sourcery/CLI-Only (= 1.8.0) - Sourcery/CLI-Only (1.8.0) - - SwiftGen (6.6.2) - - SwiftLint (0.53.0) + - SwiftGen (6.6.3) + - SwiftLint (0.54.0) - SwiftUIIntrospect (0.12.0) - SwiftyMocky (4.2.0): - Sourcery (= 1.8.0) @@ -17,7 +17,7 @@ DEPENDENCIES: - KeychainSwift (~> 20.0) - Kingfisher (~> 7.8) - SwiftGen (~> 6.6) - - SwiftLint (~> 0.5) + - SwiftLint (~> 0.54.0) - SwiftUIIntrospect (~> 0.8) - SwiftyMocky (from `https://github.com/MakeAWishFoundation/SwiftyMocky.git`, tag `4.2.0`) - Swinject (= 2.8.3) @@ -48,12 +48,12 @@ SPEC CHECKSUMS: KeychainSwift: 0ce6a4d13f7228054d1a71bb1b500448fb2ab837 Kingfisher: 1d14e9f59cbe19389f591c929000332bf70efd32 Sourcery: 6f5fe49b82b7e02e8c65560cbd52e1be67a1af2e - SwiftGen: 1366a7f71aeef49954ca5a63ba4bef6b0f24138c - SwiftLint: 5ce4d6a8ff83f1b5fd5ad5dbf30965d35af65e44 + SwiftGen: 4993cbf71cbc4886f775e26f8d5c3a1188ec9f99 + SwiftLint: c1de071d9d08c8aba837545f6254315bc900e211 SwiftUIIntrospect: 89f443402f701a9197e9e54e3c2ed00b10c32e6d SwiftyMocky: c5e96e4ff76ec6dbf5a5941aeb039b5a546954a0 Swinject: 893c9a543000ac2f10ee4cbaf0933c6992c935d5 -PODFILE CHECKSUM: 881176d00eabfe8f78d6022c56c277cf61aad22b +PODFILE CHECKSUM: 1b95af9ed204a9f360c00f6f9afa9955ad03b540 COCOAPODS: 1.15.2 diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift b/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift index aee56c70c..4635416af 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift @@ -9,7 +9,7 @@ import Foundation import Core import SwiftUI -// swiftlint:disable file_length type_body_length +// swiftlint:disable type_body_length public struct Changes: Equatable { public var shortBiography: String public var profileType: ProfileType @@ -141,14 +141,17 @@ public class EditProfileViewModel: ObservableObject { func checkChanges() { withAnimation(.easeIn(duration: 0.1)) { - self.isChanged = - [spokenLanguageConfiguration.text.isEmpty ? false : spokenLanguageConfiguration.text != userModel.spokenLanguage, - yearsConfiguration.text.isEmpty ? false : yearsConfiguration.text != String(userModel.yearOfBirth), - countriesConfiguration.text.isEmpty ? false : countriesConfiguration.text != userModel.country, - userModel.shortBiography != profileChanges.shortBiography, - profileChanges.isAvatarChanged, - profileChanges.isAvatarDeleted, - userModel.isFullProfile != profileChanges.profileType.boolValue].contains(where: { $0 == true }) + self.isChanged = [ + spokenLanguageConfiguration.text.isEmpty + ? false + : spokenLanguageConfiguration.text != userModel.spokenLanguage, + yearsConfiguration.text.isEmpty ? false : yearsConfiguration.text != String(userModel.yearOfBirth), + countriesConfiguration.text.isEmpty ? false : countriesConfiguration.text != userModel.country, + userModel.shortBiography != profileChanges.shortBiography, + profileChanges.isAvatarChanged, + profileChanges.isAvatarDeleted, + userModel.isFullProfile != profileChanges.profileType.boolValue + ].contains(where: { $0 == true }) } } @@ -365,4 +368,4 @@ public class EditProfileViewModel: ObservableObject { analytics.profileEditDoneClicked() } } -// swiftlint:enable file_length type_body_length +// swiftlint:enable type_body_length diff --git a/Theme/Theme/Theme.swift b/Theme/Theme/Theme.swift index c985b752b..0e91adb2b 100644 --- a/Theme/Theme/Theme.swift +++ b/Theme/Theme/Theme.swift @@ -12,6 +12,7 @@ private var fontsParser = FontParser() public struct Theme { + // swiftlint:disable line_length public struct Colors { public private(set) static var accentColor = ThemeAssets.accentColor.swiftUIColor public private(set) static var accentXColor = ThemeAssets.accentXColor.swiftUIColor @@ -164,6 +165,7 @@ public struct Theme { self.irreversibleAlert = irreversibleAlert } } + // swiftlint:enable line_length // Use this structure where the computed Color.uiColor() extension is not appropriate. public struct UIColors { From 32bf7d9d27bb5890a019056ae005988e01ac4388 Mon Sep 17 00:00:00 2001 From: Vadim Kuznetsov Date: Thu, 16 May 2024 16:36:26 +0300 Subject: [PATCH 07/55] [iOS] Completion doesn't work for videos in PiP mode. (#414) * chore: player refactor * chore: removed url property * chore: fixed completion and rate view for player * chore: removed commented code * chore: added error handler * chore: refactor * chore: refactor * chore: refactor * chore: refactor * chore: refactor * chore: tests * chore: refactor * chore: review requested changes * chore: merge conflict resolve --- Core/Core/Data/Model/UserSettings.swift | 13 + Course/Course.xcodeproj/project.pbxproj | 32 +- .../Unit/Subviews/YouTubeView.swift | 2 +- .../Video/EncodedVideoPlayer.swift | 61 +--- .../Video/EncodedVideoPlayerViewModel.swift | 78 +---- .../Video/PipManagerProtocol.swift | 53 +++ .../Video/PlayerControllerProtocol.swift | 15 + .../Video/PlayerDelegateProtocol.swift | 62 ++++ .../Video/PlayerServiceProtocol.swift | 63 ++++ .../Video/PlayerTrackerProtocol.swift | 318 ++++++++++++++++++ .../Video/PlayerViewController.swift | 122 +------ .../Video/PlayerViewControllerHolder.swift | 237 ++++++++----- ...btittlesView.swift => SubtitlesView.swift} | 15 +- .../Video/VideoPlayerViewModel.swift | 99 ++++-- .../Video/YouTubeVideoPlayer.swift | 10 +- .../Video/YouTubeVideoPlayerViewModel.swift | 135 +------- .../YoutubePlayerViewControllerHolder.swift | 183 ++++++++++ .../Unit/VideoPlayerViewModelTests.swift | 87 +++-- OpenEdX/DI/ScreenAssembly.swift | 105 ++++-- OpenEdX/Managers/PipManager.swift | 51 ++- 20 files changed, 1145 insertions(+), 596 deletions(-) create mode 100644 Course/Course/Presentation/Video/PipManagerProtocol.swift create mode 100644 Course/Course/Presentation/Video/PlayerControllerProtocol.swift create mode 100644 Course/Course/Presentation/Video/PlayerDelegateProtocol.swift create mode 100644 Course/Course/Presentation/Video/PlayerServiceProtocol.swift create mode 100644 Course/Course/Presentation/Video/PlayerTrackerProtocol.swift rename Course/Course/Presentation/Video/{SubtittlesView.swift => SubtitlesView.swift} (93%) create mode 100644 Course/Course/Presentation/Video/YoutubePlayerViewControllerHolder.swift diff --git a/Core/Core/Data/Model/UserSettings.swift b/Core/Core/Data/Model/UserSettings.swift index 1b25e9d6c..52ce12557 100644 --- a/Core/Core/Data/Model/UserSettings.swift +++ b/Core/Core/Data/Model/UserSettings.swift @@ -32,6 +32,19 @@ public enum StreamingQuality: Codable { public var value: String? { return String(describing: self).components(separatedBy: "(").first } + + public var resolution: CGSize { + switch self { + case .auto: + return CGSize(width: 1280, height: 720) + case .low: + return CGSize(width: 640, height: 360) + case .medium: + return CGSize(width: 854, height: 480) + case .high: + return CGSize(width: 1280, height: 720) + } + } } public enum DownloadQuality: Codable, CaseIterable { diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index 6ecf45e64..ed692cdd8 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -48,7 +48,6 @@ 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 */; }; 02F3BFDD29252E900051930C /* CourseRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F3BFDC29252E900051930C /* CourseRouter.swift */; }; 02F78AEB29E6BCA20038DE30 /* VideoPlayerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F78AEA29E6BCA20038DE30 /* VideoPlayerViewModelTests.swift */; }; 02F98A8128F8224200DE94C0 /* Discussion.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02F98A8028F8224200DE94C0 /* Discussion.framework */; }; @@ -56,6 +55,13 @@ 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 */; }; + 067B7B4E2BED339200D1768F /* PlayerTrackerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067B7B472BED339200D1768F /* PlayerTrackerProtocol.swift */; }; + 067B7B4F2BED339200D1768F /* PlayerDelegateProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067B7B482BED339200D1768F /* PlayerDelegateProtocol.swift */; }; + 067B7B502BED339200D1768F /* PlayerControllerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067B7B492BED339200D1768F /* PlayerControllerProtocol.swift */; }; + 067B7B512BED339200D1768F /* PipManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067B7B4A2BED339200D1768F /* PipManagerProtocol.swift */; }; + 067B7B522BED339200D1768F /* SubtitlesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067B7B4B2BED339200D1768F /* SubtitlesView.swift */; }; + 067B7B532BED339200D1768F /* PlayerServiceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067B7B4C2BED339200D1768F /* PlayerServiceProtocol.swift */; }; + 067B7B542BED339200D1768F /* YoutubePlayerViewControllerHolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 067B7B4D2BED339200D1768F /* YoutubePlayerViewControllerHolder.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 */; }; @@ -147,7 +153,6 @@ 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 = ""; }; - 02F066E729DC71750073E13B /* SubtittlesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubtittlesView.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; }; @@ -155,6 +160,13 @@ 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 = ""; }; + 067B7B472BED339200D1768F /* PlayerTrackerProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerTrackerProtocol.swift; sourceTree = ""; }; + 067B7B482BED339200D1768F /* PlayerDelegateProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerDelegateProtocol.swift; sourceTree = ""; }; + 067B7B492BED339200D1768F /* PlayerControllerProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerControllerProtocol.swift; sourceTree = ""; }; + 067B7B4A2BED339200D1768F /* PipManagerProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PipManagerProtocol.swift; sourceTree = ""; }; + 067B7B4B2BED339200D1768F /* SubtitlesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SubtitlesView.swift; sourceTree = ""; }; + 067B7B4C2BED339200D1768F /* PlayerServiceProtocol.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PlayerServiceProtocol.swift; sourceTree = ""; }; + 067B7B4D2BED339200D1768F /* YoutubePlayerViewControllerHolder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = YoutubePlayerViewControllerHolder.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 = ""; }; @@ -459,8 +471,14 @@ 070019AA28F6F79E00D5FC78 /* Video */ = { isa = PBXGroup; children = ( + 067B7B4A2BED339200D1768F /* PipManagerProtocol.swift */, + 067B7B492BED339200D1768F /* PlayerControllerProtocol.swift */, + 067B7B482BED339200D1768F /* PlayerDelegateProtocol.swift */, + 067B7B4C2BED339200D1768F /* PlayerServiceProtocol.swift */, + 067B7B472BED339200D1768F /* PlayerTrackerProtocol.swift */, + 067B7B4B2BED339200D1768F /* SubtitlesView.swift */, + 067B7B4D2BED339200D1768F /* YoutubePlayerViewControllerHolder.swift */, 065275342BB1B39C0093BCCA /* PlayerViewControllerHolder.swift */, - 02F066E729DC71750073E13B /* SubtittlesView.swift */, 0766DFCB299AA7A600EBEF6A /* YouTubeVideoPlayer.swift */, 022F8E152A1DFBC6008EFAB9 /* YouTubeVideoPlayerViewModel.swift */, 0766DFCD299AB26D00EBEF6A /* EncodedVideoPlayer.swift */, @@ -834,6 +852,7 @@ buildActionMask = 2147483647; files = ( 06FD7EE32B1F3FF6008D632B /* DropdownAnimationModifier.swift in Sources */, + 067B7B542BED339200D1768F /* YoutubePlayerViewControllerHolder.swift in Sources */, 02FFAD0D29E4347300140E46 /* VideoPlayerViewModel.swift in Sources */, 02454CA42A26193F0043052A /* WebView.swift in Sources */, 022C64DA29ACEC50000F532B /* HandoutsViewModel.swift in Sources */, @@ -844,6 +863,7 @@ BA58CF612B471041005B102E /* VideoDownloadQualityBarView.swift in Sources */, 0270210328E736E700F54332 /* CourseOutlineView.swift in Sources */, 068DDA602B1E198700FF8CCB /* CourseUnitVerticalsDropdownView.swift in Sources */, + 067B7B512BED339200D1768F /* PipManagerProtocol.swift in Sources */, 022C64E029ADEA9B000F532B /* Data_UpdatesResponse.swift in Sources */, 02D4FC2E2BBD7C9C00C47748 /* MessageSectionView.swift in Sources */, 02454CA02A2618E70043052A /* YouTubeView.swift in Sources */, @@ -874,20 +894,24 @@ BAAD62C82AFD00EE000E6103 /* CourseStructureNestedListView.swift in Sources */, 02A8076829474831007F53AB /* CourseVerticalView.swift in Sources */, 97E7DF0F2B7C852A00A2A09B /* DatesStatusInfoView.swift in Sources */, + 067B7B4F2BED339200D1768F /* PlayerDelegateProtocol.swift in Sources */, 0231124D28EDA804002588FB /* CourseUnitView.swift in Sources */, 027020FC28E7362100F54332 /* Data_CourseOutlineResponse.swift in Sources */, DB7D6EB02ADFDA0E0036BB13 /* CourseDates.swift in Sources */, + 067B7B532BED339200D1768F /* PlayerServiceProtocol.swift in Sources */, BAD9CA2D2B2736BB00DE790A /* LessonLineProgressView.swift in Sources */, 060E8BCA2B5FD68C0080C952 /* UnitStack.swift in Sources */, 0295C889299BBE8200ABE571 /* CourseNavigationView.swift in Sources */, 06FD7EDF2B1F29F3008D632B /* CourseVerticalImageView.swift in Sources */, BAC0E0DB2B32F0AE006B68A9 /* CourseVideoDownloadBarViewModel.swift in Sources */, DB7D6EAE2ADFCB4A0036BB13 /* CourseDatesViewModel.swift in Sources */, - 02F066E829DC71750073E13B /* SubtittlesView.swift in Sources */, + 067B7B522BED339200D1768F /* SubtitlesView.swift in Sources */, 07DE59862BECB868001CBFBC /* CourseAnalytics.swift in Sources */, 022C64E229ADEB83000F532B /* CourseUpdate.swift in Sources */, BA58CF642B471363005B102E /* VideoDownloadQualityContainerView.swift in Sources */, BA58CF5D2B3D804D005B102E /* CourseStorage.swift in Sources */, + 067B7B502BED339200D1768F /* PlayerControllerProtocol.swift in Sources */, + 067B7B4E2BED339200D1768F /* PlayerTrackerProtocol.swift in Sources */, 02454CA62A26196C0043052A /* UnknownView.swift in Sources */, 0766DFD0299AB29000EBEF6A /* PlayerViewController.swift in Sources */, 022C64DC29ACFDEE000F532B /* Data_HandoutsResponse.swift in Sources */, diff --git a/Course/Course/Presentation/Unit/Subviews/YouTubeView.swift b/Course/Course/Presentation/Unit/Subviews/YouTubeView.swift index 934d75803..3be341524 100644 --- a/Course/Course/Presentation/Unit/Subviews/YouTubeView.swift +++ b/Course/Course/Presentation/Unit/Subviews/YouTubeView.swift @@ -24,7 +24,7 @@ struct YouTubeView: View { var body: some View { let vm = Container.shared.resolve( YouTubeVideoPlayerViewModel.self, - arguments: url, + arguments: URL(string: url), blockID, courseID, languages, diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift index cdea26e70..72e0c5dde 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayer.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayer.swift @@ -27,10 +27,7 @@ public struct EncodedVideoPlayer: View { @State private var orientation = UIDevice.current.orientation @State private var isLoading: Bool = true @State private var isAnimating: Bool = false - @State private var isViewedOnce: Bool = false - @State private var currentTime: Double = 0 @State private var isOrientationChanged: Bool = false - @State private var pause: Bool = false @State var showAlert = false @State var alertMessage: String? { @@ -57,32 +54,13 @@ public struct EncodedVideoPlayer: View { VStack(spacing: 10) { HStack { VStack { - PlayerViewController( - videoURL: viewModel.url, - playerHolder: viewModel.controllerHolder, - bitrate: viewModel.getVideoResolution(), - progress: { progress in - if progress >= 0.8 { - if !isViewedOnce { - Task { - await viewModel.blockCompletionRequest() - } - isViewedOnce = true - } - } - if progress == 1 { - viewModel.router.presentAppReview() - } - - }, seconds: { seconds in - currentTime = seconds - }) + PlayerViewController(playerController: viewModel.controller) .aspectRatio(16 / 9, contentMode: .fit) .frame(minWidth: playerWidth(for: reader.size)) .cornerRadius(12) .onAppear { - if !viewModel.controllerHolder.isPlayingInPip, - !viewModel.controllerHolder.isOtherPlayerInPip { + if !viewModel.isPlayingInPip, + !viewModel.isOtherPlayerInPip { viewModel.controller.player?.play() } } @@ -91,9 +69,9 @@ public struct EncodedVideoPlayer: View { } } if isHorizontal { - SubtittlesView( + SubtitlesView( languages: viewModel.languages, - currentTime: $currentTime, + currentTime: $viewModel.currentTime, viewModel: viewModel, scrollTo: { date in viewModel.controller.player?.seek( @@ -103,15 +81,15 @@ public struct EncodedVideoPlayer: View { ) ) viewModel.controller.player?.play() - pauseScrolling() - currentTime = (date.secondsSinceMidnight() + 1) + viewModel.pauseScrolling() + viewModel.currentTime = (date.secondsSinceMidnight() + 1) }) } } if !isHorizontal { - SubtittlesView( + SubtitlesView( languages: viewModel.languages, - currentTime: $currentTime, + currentTime: $viewModel.currentTime, viewModel: viewModel, scrollTo: { date in viewModel.controller.player?.seek( @@ -121,8 +99,8 @@ public struct EncodedVideoPlayer: View { ) ) viewModel.controller.player?.play() - pauseScrolling() - currentTime = (date.secondsSinceMidnight() + 1) + viewModel.pauseScrolling() + viewModel.currentTime = (date.secondsSinceMidnight() + 1) }) } } @@ -134,17 +112,11 @@ public struct EncodedVideoPlayer: View { viewModel.controller.player?.allowsExternalPlayback = false } .onAppear { + viewModel.controller.player?.allowsExternalPlayback = true viewModel.controller.setNeedsStatusBarAppearanceUpdate() } } - private func pauseScrolling() { - pause = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - self.pause = false - } - } - private func playerWidth(for size: CGSize) -> CGFloat { if isHorizontal { return size.width * 0.6 @@ -163,17 +135,10 @@ struct EncodedVideoPlayer_Previews: PreviewProvider { static var previews: some View { EncodedVideoPlayer( viewModel: EncodedVideoPlayerViewModel( - url: URL(string: "")!, - blockID: "", - courseID: "", languages: [], playerStateSubject: CurrentValueSubject(nil), - interactor: CourseInteractor(repository: CourseRepositoryMock()), - router: CourseRouterMock(), - appStorage: CoreStorageMock(), connectivity: Connectivity(), - pipManager: PipManagerProtocolMock(), - selectedCourseTab: 0 + playerHolder: PlayerViewControllerHolder.mock ), isOnScreen: true ) diff --git a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift index bb5eb8d3e..b7bdaed9f 100644 --- a/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/EncodedVideoPlayerViewModel.swift @@ -10,83 +10,7 @@ import Core import Combine public class EncodedVideoPlayerViewModel: VideoPlayerViewModel { - - let url: URL? - - let controllerHolder: PlayerViewControllerHolder var controller: AVPlayerViewController { - controllerHolder.playerController - } - private var subscription = Set() - - public init( - url: URL?, - blockID: String, - courseID: String, - languages: [SubtitleUrl], - playerStateSubject: CurrentValueSubject, - interactor: CourseInteractorProtocol, - router: CourseRouter, - appStorage: CoreStorage, - connectivity: ConnectivityProtocol, - pipManager: PipManagerProtocol, - selectedCourseTab: Int - ) { - self.url = url - - if let holder = pipManager.holder( - for: url, - blockID: blockID, - courseID: courseID, - selectedCourseTab: selectedCourseTab - ) { - controllerHolder = holder - } else { - let holder = PlayerViewControllerHolder( - url: url, - blockID: blockID, - courseID: courseID, - selectedCourseTab: selectedCourseTab - ) - controllerHolder = holder - } - - super.init(blockID: blockID, - courseID: courseID, - languages: languages, - interactor: interactor, - router: router, - appStorage: appStorage, - connectivity: connectivity) - - playerStateSubject.sink(receiveValue: { [weak self] state in - switch state { - case .pause: - if self?.controllerHolder.isPlayingInPip != true { - self?.controller.player?.pause() - } - case .kill: - if self?.controllerHolder.isPlayingInPip != true { - self?.controller.player?.replaceCurrentItem(with: nil) - } - case .none: - break - } - }).store(in: &subscription) - } - - func getVideoResolution() -> CGSize { - switch appStorage.userSettings?.streamingQuality { - case .auto: - return CGSize(width: 1280, height: 720) - case .low: - return CGSize(width: 640, height: 360) - case .medium: - return CGSize(width: 854, height: 480) - case .high: - return CGSize(width: 1280, height: 720) - case .none: - return CGSize(width: 1280, height: 720) - } + (playerHolder.playerController as? AVPlayerViewController) ?? AVPlayerViewController() } } diff --git a/Course/Course/Presentation/Video/PipManagerProtocol.swift b/Course/Course/Presentation/Video/PipManagerProtocol.swift new file mode 100644 index 000000000..c92550cb3 --- /dev/null +++ b/Course/Course/Presentation/Video/PipManagerProtocol.swift @@ -0,0 +1,53 @@ +// +// PipManagerProtocol.swift +// Course +// +// Created by Vadim Kuznetsov on 22.04.24. +// + +import Combine +import Foundation + +public protocol PipManagerProtocol { + var isPipActive: Bool { get } + var isPipPlaying: Bool { get } + + func holder( + for url: URL?, + blockID: String, + courseID: String, + selectedCourseTab: Int + ) -> PlayerViewControllerHolderProtocol? + func set(holder: PlayerViewControllerHolderProtocol) + func remove(holder: PlayerViewControllerHolderProtocol) + func restore(holder: PlayerViewControllerHolderProtocol) async throws + func pipRatePublisher() -> AnyPublisher? + func pauseCurrentPipVideo() +} + +#if DEBUG +public class PipManagerProtocolMock: PipManagerProtocol { + public var isPipActive: Bool { + false + } + + public var isPipPlaying: Bool { + false + } + + public init() {} + public func holder( + for url: URL?, + blockID: String, + courseID: String, + selectedCourseTab: Int + ) -> PlayerViewControllerHolderProtocol? { + return nil + } + public func set(holder: PlayerViewControllerHolderProtocol) {} + public func remove(holder: PlayerViewControllerHolderProtocol) {} + public func restore(holder: PlayerViewControllerHolderProtocol) async throws {} + public func pipRatePublisher() -> AnyPublisher? { nil } + public func pauseCurrentPipVideo() {} +} +#endif diff --git a/Course/Course/Presentation/Video/PlayerControllerProtocol.swift b/Course/Course/Presentation/Video/PlayerControllerProtocol.swift new file mode 100644 index 000000000..df376e466 --- /dev/null +++ b/Course/Course/Presentation/Video/PlayerControllerProtocol.swift @@ -0,0 +1,15 @@ +// +// PlayerControllerProtocol.swift +// Course +// +// Created by Vadim Kuznetsov on 22.04.24. +// + +import Foundation + +public protocol PlayerControllerProtocol { + func play() + func pause() + func seekTo(to date: Date) + func stop() +} diff --git a/Course/Course/Presentation/Video/PlayerDelegateProtocol.swift b/Course/Course/Presentation/Video/PlayerDelegateProtocol.swift new file mode 100644 index 000000000..1297e9ddf --- /dev/null +++ b/Course/Course/Presentation/Video/PlayerDelegateProtocol.swift @@ -0,0 +1,62 @@ +// +// PlayerDelegateProtocol.swift +// Course +// +// Created by Vadim Kuznetsov on 22.04.24. +// + +import AVKit + +public protocol PlayerDelegateProtocol: AVPlayerViewControllerDelegate { + var isPlayingInPip: Bool { get } + var playerHolder: PlayerViewControllerHolderProtocol? { get set } + init(pipManager: PipManagerProtocol) +} + +public class PlayerDelegate: NSObject, PlayerDelegateProtocol { + private(set) public var isPlayingInPip: Bool = false + private let pipManager: PipManagerProtocol + weak public var playerHolder: PlayerViewControllerHolderProtocol? + + required public init(pipManager: PipManagerProtocol) { + self.pipManager = pipManager + super.init() + } + + public func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) { + isPlayingInPip = true + if let holder = playerHolder { + pipManager.set(holder: holder) + } + } + + public func playerViewController( + _ playerViewController: AVPlayerViewController, + failedToStartPictureInPictureWithError error: any Error + ) { + isPlayingInPip = false + if let holder = playerHolder { + pipManager.remove(holder: holder) + } + } + + public func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) { + isPlayingInPip = false + if let holder = playerHolder { + pipManager.remove(holder: holder) + } + } + + public func playerViewControllerRestoreUserInterfaceForPictureInPictureStop( + _ playerViewController: AVPlayerViewController + ) async -> Bool { + do { + if let holder = playerHolder { + try await pipManager.restore(holder: holder) + } + return true + } catch { + return false + } + } +} diff --git a/Course/Course/Presentation/Video/PlayerServiceProtocol.swift b/Course/Course/Presentation/Video/PlayerServiceProtocol.swift new file mode 100644 index 000000000..3619de512 --- /dev/null +++ b/Course/Course/Presentation/Video/PlayerServiceProtocol.swift @@ -0,0 +1,63 @@ +// +// PlayerServiceProtocol.swift +// Course +// +// Created by Vadim Kuznetsov on 22.04.24. +// + +import SwiftUI + +public protocol PlayerServiceProtocol { + var router: CourseRouter { get } + + init(courseID: String, blockID: String, interactor: CourseInteractorProtocol, router: CourseRouter) + func blockCompletionRequest() async throws + func presentAppReview() + func presentView(transitionStyle: UIModalTransitionStyle, animated: Bool, content: () -> any View) + func getSubtitles(url: String, selectedLanguage: String) async throws -> [Subtitle] +} + +public class PlayerService: PlayerServiceProtocol { + private let courseID: String + private let blockID: String + private let interactor: CourseInteractorProtocol + public let router: CourseRouter + + public required init( + courseID: String, + blockID: String, + interactor: CourseInteractorProtocol, + router: CourseRouter + ) { + self.courseID = courseID + self.blockID = blockID + self.interactor = interactor + self.router = router + } + + @MainActor + public func blockCompletionRequest() async throws { + try await interactor.blockCompletionRequest(courseID: courseID, blockID: blockID) + NotificationCenter.default.post( + name: NSNotification.blockCompletion, + object: nil + ) + } + + @MainActor + public func presentAppReview() { + router.presentAppReview() + } + + @MainActor + public func presentView(transitionStyle: UIModalTransitionStyle, animated: Bool, content: () -> any View) { + router.presentView(transitionStyle: transitionStyle, animated: animated, content: content) + } + + public func getSubtitles(url: String, selectedLanguage: String) async throws -> [Subtitle] { + try await interactor.getSubtitles( + url: url, + selectedLanguage: selectedLanguage + ) + } +} diff --git a/Course/Course/Presentation/Video/PlayerTrackerProtocol.swift b/Course/Course/Presentation/Video/PlayerTrackerProtocol.swift new file mode 100644 index 000000000..4487bab29 --- /dev/null +++ b/Course/Course/Presentation/Video/PlayerTrackerProtocol.swift @@ -0,0 +1,318 @@ +// +// PlayerTrackerProtocol.swift +// Course +// +// Created by Vadim Kuznetsov on 22.04.24. +// + +import AVKit +import Combine +import Foundation + +public protocol PlayerTrackerProtocol { + associatedtype Player + var player: Player? { get } + var duration: Double { get } + var progress: Double { get } + var isPlaying: Bool { get } + var isReady: Bool { get } + init(url: URL?) + + func getTimePublisher() -> AnyPublisher + func getRatePublisher() -> AnyPublisher + func getFinishPublisher() -> AnyPublisher + func getReadyPublisher() -> AnyPublisher +} + +#if DEBUG +class PlayerTrackerProtocolMock: PlayerTrackerProtocol { + let player: AVPlayer? + var duration: Double { + 1 + } + var progress: Double { + 0 + } + let isPlaying = false + let isReady = false + private let timePublisher: CurrentValueSubject + private let ratePublisher: CurrentValueSubject + private let finishPublisher: PassthroughSubject + private let readyPublisher: PassthroughSubject + + required init(url: URL?) { + var item: AVPlayerItem? + if let url = url { + item = AVPlayerItem(url: url) + } + self.player = AVPlayer(playerItem: item) + timePublisher = CurrentValueSubject(0) + ratePublisher = CurrentValueSubject(0) + finishPublisher = PassthroughSubject() + readyPublisher = PassthroughSubject() + } + + func getTimePublisher() -> AnyPublisher { + timePublisher.eraseToAnyPublisher() + } + + func getRatePublisher() -> AnyPublisher { + ratePublisher.eraseToAnyPublisher() + } + + func getFinishPublisher() -> AnyPublisher { + finishPublisher.eraseToAnyPublisher() + } + + func getReadyPublisher() -> AnyPublisher { + readyPublisher.eraseToAnyPublisher() + } + + func sendProgress(_ progress: Double) { + timePublisher.send(progress) + } + + func sendFinish() { + finishPublisher.send() + } +} +#endif +// MARK: Video +public class PlayerTracker: PlayerTrackerProtocol { + public var isReady: Bool = false + public let player: AVPlayer? + public var duration: Double { + player?.currentItem?.duration.seconds ?? .nan + } + public var isPlaying: Bool { + (player?.rate ?? 0) > 0 + } + + public var progress: Double { + let currentTime = player?.currentTime().seconds ?? 0 + guard !currentTime.isNaN && !currentTime.isInfinite && duration.isNormal + else { + return 0 + } + + return currentTime/duration + } + + private var cancellations: [AnyCancellable] = [] + private var timeObserver: Any? + private let timePublisher: CurrentValueSubject + private let ratePublisher: CurrentValueSubject + private let finishPublisher: PassthroughSubject + private let readyPublisher: PassthroughSubject + + public required init(url: URL?) { + var item: AVPlayerItem? + if let url = url { + item = AVPlayerItem(url: url) + } + self.player = AVPlayer(playerItem: item) + timePublisher = CurrentValueSubject(player?.currentTime().seconds ?? 0) + ratePublisher = CurrentValueSubject(player?.rate ?? 0) + finishPublisher = PassthroughSubject() + readyPublisher = PassthroughSubject() + observe() + } + + deinit { + clear() + } + + private func observe() { + let interval = CMTime( + seconds: 0.1, + preferredTimescale: CMTimeScale(NSEC_PER_SEC) + ) + + timeObserver = player?.addPeriodicTimeObserver(forInterval: interval, queue: .main) {[weak self] time in + self?.timePublisher.send(time.seconds) + } + + player?.publisher(for: \.rate) + .sink {[weak self] rate in + self?.ratePublisher.send(rate) + } + .store(in: &cancellations) + + player?.publisher(for: \.status) + .sink {[weak self] status in + guard let strongSelf = self else { return } + strongSelf.isReady = status == .readyToPlay + strongSelf.readyPublisher.send(strongSelf.isReady) + } + .store(in: &cancellations) + + NotificationCenter.default.publisher( + for: AVPlayerItem.didPlayToEndTimeNotification, + object: player?.currentItem + ) + .sink {[weak self] _ in + if self?.player?.currentItem != nil { + self?.finishPublisher.send() + } + } + .store(in: &cancellations) + } + + private func clear() { + if let observer = timeObserver { + player?.removeTimeObserver(observer) + } + cancellations.removeAll() + } + + public func getTimePublisher() -> AnyPublisher { + timePublisher + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + public func getRatePublisher() -> AnyPublisher { + ratePublisher + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + public func getFinishPublisher() -> AnyPublisher { + finishPublisher + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + public func getReadyPublisher() -> AnyPublisher { + readyPublisher + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } +} + +// MARK: YouTube +import YouTubePlayerKit +public class YoutubePlayerTracker: PlayerTrackerProtocol { + public var isReady: Bool = false + + public let player: YouTubePlayer? + public var duration: Double = 0 + public var isPlaying: Bool { + player?.isPlaying ?? false + } + + public var progress: Double { + timePublisher.value / duration + } + + private var cancellations: [AnyCancellable] = [] + private let timePublisher: CurrentValueSubject + private let ratePublisher: CurrentValueSubject + private let finishPublisher: PassthroughSubject + private let readyPublisher: PassthroughSubject + + public required init(url: URL?) { + if let url = url { + let videoID = url.absoluteString.replacingOccurrences(of: "https://www.youtube.com/watch?v=", with: "") + let configuration = YouTubePlayer.Configuration(configure: { + $0.playInline = true + $0.showFullscreenButton = true + $0.allowsPictureInPictureMediaPlayback = false + $0.showControls = true + $0.useModestBranding = false + $0.progressBarColor = .white + $0.showRelatedVideos = false + $0.showCaptions = false + $0.showAnnotations = false + $0.customUserAgent = """ + Mozilla/5.0 (iPod; U; CPU iPhone OS 4_3_3 like Mac OS X; ja-jp) + AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5 + """ + }) + self.player = YouTubePlayer(source: .video(id: videoID), configuration: configuration) + self.player?.pause() + } else { + self.player = nil + } + + timePublisher = CurrentValueSubject(0) + ratePublisher = CurrentValueSubject(0) + finishPublisher = PassthroughSubject() + readyPublisher = PassthroughSubject() + observe() + } + + deinit { + clear() + } + + private func observe() { + player?.durationPublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] duration in + self?.duration = duration.value + } + .store(in: &cancellations) + + player?.currentTimePublisher(updateInterval: 0.1) + .sink { [weak self] time in + self?.timePublisher.send(time.value) + } + .store(in: &cancellations) + player?.statePublisher + .sink { [weak self] state in + switch state { + case .ready: + self?.isReady = true + self?.readyPublisher.send(true) + default: + self?.isReady = false + self?.readyPublisher.send(false) + } + } + .store(in: &cancellations) + + player?.playbackStatePublisher + .sink { [weak self] state in + guard let strongSelf = self else { return } + switch state { + case .playing: + strongSelf.ratePublisher.send(1) + case .ended: + strongSelf.ratePublisher.send(0) + strongSelf.finishPublisher.send() + default: + strongSelf.ratePublisher.send(0) + } + } + .store(in: &cancellations) + } + + private func clear() { + cancellations.removeAll() + } + + public func getTimePublisher() -> AnyPublisher { + timePublisher + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + public func getRatePublisher() -> AnyPublisher { + ratePublisher + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + public func getFinishPublisher() -> AnyPublisher { + finishPublisher + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + public func getReadyPublisher() -> AnyPublisher { + readyPublisher + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } +} diff --git a/Course/Course/Presentation/Video/PlayerViewController.swift b/Course/Course/Presentation/Video/PlayerViewController.swift index 0bb477635..573a1195e 100644 --- a/Course/Course/Presentation/Video/PlayerViewController.swift +++ b/Course/Course/Presentation/Video/PlayerViewController.swift @@ -11,131 +11,17 @@ import SwiftUI import _AVKit_SwiftUI struct PlayerViewController: UIViewControllerRepresentable { - - var videoURL: URL? - var videoResolution: CGSize - var playerHolder: PlayerViewControllerHolder - var progress: ((Float) -> Void) - var seconds: ((Double) -> Void) - - init( - videoURL: URL?, - playerHolder: PlayerViewControllerHolder, - bitrate: CGSize, - progress: @escaping ((Float) -> Void), - seconds: @escaping ((Double) -> Void) - ) { - self.videoURL = videoURL - self.playerHolder = playerHolder - self.videoResolution = bitrate - self.progress = progress - self.seconds = seconds - } - + var playerController: AVPlayerViewController + func makeUIViewController(context: Context) -> AVPlayerViewController { - context.coordinator.currentHolder = playerHolder - if playerHolder.isPlayingInPip { - return playerHolder.playerController - } - - 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 - self.progress(progress) - self.seconds(seconds) - } - do { try AVAudioSession.sharedInstance().setCategory(.playback) } catch { print(error.localizedDescription) } - return controller - } - - func updateUIViewController(_ playerController: AVPlayerViewController, context: Context) { - let asset = playerController.player?.currentItem?.asset as? AVURLAsset - 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 - - context.coordinator.setPlayer(player) { progress, seconds in - self.progress(progress) - self.seconds(seconds) - } - } - } - - func makeCoordinator() -> Coordinator { - Coordinator() + return playerController } - static func dismantleUIViewController(_ uiViewController: AVPlayerViewController, coordinator: Coordinator) { - coordinator.setPlayer(nil) { _, _ in } - } - - class Coordinator { - var currentPlayer: AVPlayer? - var observer: Any? - var cancellations: [AnyCancellable] = [] - weak var currentHolder: PlayerViewControllerHolder? - - func player(from playerController: AVPlayerViewController) -> AVPlayer? { - var player = playerController.player - if player == nil { - player = AVPlayer() - player?.allowsExternalPlayback = true - playerController.player = player - } - return player - } - - func setPlayer(_ player: AVPlayer?, currentProgress: @escaping ((Float, Double) -> Void)) { - cancellations.removeAll() - if let observer = observer { - currentPlayer?.removeTimeObserver(observer) - if currentHolder?.isPlayingInPip == false { - currentPlayer?.pause() - } - } - - let interval = CMTime( - seconds: 0.1, - preferredTimescale: CMTimeScale(NSEC_PER_SEC) - ) - - 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 } - let totalSeconds = CMTimeGetSeconds(duration) - progress = Float(currentSeconds / totalSeconds) - currentProgress(progress, currentSeconds) - } - - player?.publisher(for: \.rate) - .sink {[weak self] rate in - guard rate > 0 else { return } - 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 - - } - } + func updateUIViewController(_ playerController: AVPlayerViewController, context: Context) {} } diff --git a/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift b/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift index 3ba64b192..fac9ef02a 100644 --- a/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift +++ b/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift @@ -7,125 +7,212 @@ import AVKit 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 pipRatePublisher() -> AnyPublisher? - func pauseCurrentPipVideo() -} - -#if DEBUG -public class PipManagerProtocolMock: PipManagerProtocol { - public var isPipActive: Bool { - false - } +public protocol PlayerViewControllerHolderProtocol: AnyObject { + var url: URL? { get } + var blockID: String { get } + var courseID: String { get } + var selectedCourseTab: Int { get } + var playerController: PlayerControllerProtocol? { get } + var isPlaying: Bool { get } + var isPlayingInPip: Bool { get } + var isOtherPlayerInPipPlaying: Bool { get } - public init() {} - public func holder( - for url: URL?, + init( + url: URL?, blockID: String, courseID: String, - selectedCourseTab: Int - ) -> PlayerViewControllerHolder? { - return nil - } - 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() {} + selectedCourseTab: Int, + videoResolution: CGSize, + pipManager: PipManagerProtocol, + playerTracker: any PlayerTrackerProtocol, + playerDelegate: PlayerDelegateProtocol?, + playerService: PlayerServiceProtocol + ) + func getTimePublisher() -> AnyPublisher + func getErrorPublisher() -> AnyPublisher + func getRatePublisher() -> AnyPublisher + func getReadyPublisher() -> AnyPublisher + func getService() -> PlayerServiceProtocol + func sendCompletion() async } -#endif -public class PlayerViewControllerHolder: NSObject, AVPlayerViewControllerDelegate { +public class PlayerViewControllerHolder: PlayerViewControllerHolderProtocol { public let url: URL? public let blockID: String public let courseID: String public let selectedCourseTab: Int - public var isPlayingInPip: Bool = false - public var isOtherPlayerInPip: Bool { + + public var isPlaying: Bool { + playerTracker.isPlaying + } + public var timePublisher: AnyPublisher { + playerTracker.getTimePublisher() + } + + public var isPlayingInPip: Bool { + playerDelegate?.isPlayingInPip ?? false + } + + public var isOtherPlayerInPipPlaying: Bool { let holder = pipManager.holder( for: url, blockID: blockID, courseID: courseID, selectedCourseTab: selectedCourseTab ) - return holder == nil && pipManager.isPipActive + return holder == nil && pipManager.isPipActive && pipManager.isPipPlaying } - - private let pipManager: PipManagerProtocol - - public lazy var playerController: AVPlayerViewController = { + public var duration: Double { + playerTracker.duration + } + private let playerTracker: any PlayerTrackerProtocol + private let playerDelegate: PlayerDelegateProtocol? + private let playerService: PlayerServiceProtocol + private let videoResolution: CGSize + private let errorPublisher = PassthroughSubject() + private var isViewedOnce: Bool = false + private var cancellations: [AnyCancellable] = [] + + let pipManager: PipManagerProtocol + + public lazy var playerController: PlayerControllerProtocol? = { let playerController = AVPlayerViewController() - playerController.delegate = self + playerController.modalPresentationStyle = .fullScreen + playerController.allowsPictureInPicturePlayback = true + playerController.canStartPictureInPictureAutomaticallyFromInline = true + playerController.delegate = playerDelegate + playerController.player = playerTracker.player as? AVPlayer + playerController.player?.currentItem?.preferredMaximumResolution = videoResolution return playerController }() - - public init( + + required public init( url: URL?, blockID: String, courseID: String, - selectedCourseTab: Int + selectedCourseTab: Int, + videoResolution: CGSize, + pipManager: PipManagerProtocol, + playerTracker: any PlayerTrackerProtocol, + playerDelegate: PlayerDelegateProtocol?, + playerService: PlayerServiceProtocol ) { self.url = url self.blockID = blockID self.courseID = courseID self.selectedCourseTab = selectedCourseTab - self.pipManager = Container.shared.resolve(PipManagerProtocol.self)! + self.videoResolution = videoResolution + self.pipManager = pipManager + self.playerTracker = playerTracker + self.playerDelegate = playerDelegate + self.playerService = playerService + addObservers() } - public func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) { - isPlayingInPip = true - pipManager.set(holder: self) + private func addObservers() { + timePublisher + .sink {[weak self] _ in + guard let strongSelf = self else { return } + if strongSelf.playerTracker.progress > 0.8 && !strongSelf.isViewedOnce { + strongSelf.isViewedOnce = true + Task { + await strongSelf.sendCompletion() + } + } + } + .store(in: &cancellations) + playerTracker.getFinishPublisher() + .sink { [weak self] in + self?.playerService.presentAppReview() + } + .store(in: &cancellations) + playerTracker.getRatePublisher() + .sink {[weak self] rate in + guard rate > 0 else { return } + self?.pausePipIfNeed() + } + .store(in: &cancellations) + pipManager.pipRatePublisher()? + .sink {[weak self] rate in + guard rate > 0, self?.isPlayingInPip == false else { return } + self?.playerController?.pause() + } + .store(in: &cancellations) + } + + public func pausePipIfNeed() { + if !isPlayingInPip { + pipManager.pauseCurrentPipVideo() + } } - public func playerViewController( - _ playerViewController: AVPlayerViewController, - failedToStartPictureInPictureWithError error: any Error - ) { - isPlayingInPip = false - pipManager.remove(holder: self) + public func getTimePublisher() -> AnyPublisher { + playerTracker.getTimePublisher() + } + + public func getErrorPublisher() -> AnyPublisher { + errorPublisher + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() } - public func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) { - isPlayingInPip = false - pipManager.remove(holder: self) + public func getRatePublisher() -> AnyPublisher { + playerTracker.getRatePublisher() } - public func playerViewControllerRestoreUserInterfaceForPictureInPictureStop( - _ playerViewController: AVPlayerViewController - ) async -> Bool { + public func getReadyPublisher() -> AnyPublisher { + playerTracker.getReadyPublisher() + } + + public func getService() -> PlayerServiceProtocol { + playerService + } + + public func sendCompletion() async { do { - try await Container.shared.resolve(PipManagerProtocol.self)?.restore(holder: self) - return true + try await playerService.blockCompletionRequest() } catch { - return false + errorPublisher.send(error) } } +} + +extension PlayerViewControllerHolder { + static var mock: PlayerViewControllerHolder { + PlayerViewControllerHolder( + url: URL(string: "")!, + blockID: "", + courseID: "", + selectedCourseTab: 0, + videoResolution: .zero, + pipManager: PipManagerProtocolMock(), + playerTracker: PlayerTrackerProtocolMock(url: URL(string: "")), + playerDelegate: nil, + playerService: PlayerService( + courseID: "", + blockID: "", + interactor: CourseInteractor.mock, + router: CourseRouterMock() + ) + ) + } +} + +extension AVPlayerViewController: PlayerControllerProtocol { + public func play() { + player?.play() + } - 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 + public func pause() { + player?.pause() } - public func pausePipIfNeed() { - if !isPlayingInPip { - pipManager.pauseCurrentPipVideo() - } + public func seekTo(to date: Date) { + player?.seek(to: date) } - public func pipRatePublisher() -> AnyPublisher? { - pipManager.pipRatePublisher() + public func stop() { + player?.replaceCurrentItem(with: nil) } } diff --git a/Course/Course/Presentation/Video/SubtittlesView.swift b/Course/Course/Presentation/Video/SubtitlesView.swift similarity index 93% rename from Course/Course/Presentation/Video/SubtittlesView.swift rename to Course/Course/Presentation/Video/SubtitlesView.swift index f2a1bf81d..97dfe48ad 100644 --- a/Course/Course/Presentation/Video/SubtittlesView.swift +++ b/Course/Course/Presentation/Video/SubtitlesView.swift @@ -1,5 +1,5 @@ // -// SubtittlesView.swift +// SubtitlesView.swift // Course // // Created by  Stepanok Ivan on 04.04.2023. @@ -15,7 +15,7 @@ public struct Subtitle { var text: String } -public struct SubtittlesView: View { +public struct SubtitlesView: View { @Environment (\.isHorizontal) private var isHorizontal @@ -113,20 +113,19 @@ public struct SubtittlesView: View { } #if DEBUG +import Combine struct SubtittlesView_Previews: PreviewProvider { static var previews: some View { - SubtittlesView( + SubtitlesView( languages: [SubtitleUrl(language: "fr", url: "url"), SubtitleUrl(language: "uk", url: "url2")], currentTime: .constant(0), viewModel: VideoPlayerViewModel( - blockID: "", courseID: "", languages: [], - interactor: CourseInteractor(repository: CourseRepositoryMock()), - router: CourseRouterMock(), - appStorage: CoreStorageMock(), - connectivity: Connectivity() + playerStateSubject: CurrentValueSubject(nil), + connectivity: Connectivity(), + playerHolder: PlayerViewControllerHolder.mock ), scrollTo: {_ in } ) } diff --git a/Course/Course/Presentation/Video/VideoPlayerViewModel.swift b/Course/Course/Presentation/Video/VideoPlayerViewModel.swift index 27b214068..8e95a31f0 100644 --- a/Course/Course/Presentation/Video/VideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/VideoPlayerViewModel.swift @@ -8,16 +8,14 @@ import Foundation import Core import _AVKit_SwiftUI +import Combine public class VideoPlayerViewModel: ObservableObject { - - private var blockID: String - private var courseID: String + @Published var pause: Bool = false + @Published var currentTime: Double = 0 + @Published var isLoading: Bool = true - private let interactor: CourseInteractorProtocol public let connectivity: ConnectivityProtocol - public let router: CourseRouter - public let appStorage: CoreStorage private var subtitlesDownloaded: Bool = false @Published var subtitles: [Subtitle] = [] @@ -31,50 +29,78 @@ public class VideoPlayerViewModel: ObservableObject { showError = errorMessage != nil } } + var isPlayingInPip: Bool { + playerHolder.isPlayingInPip + } + + var isOtherPlayerInPip: Bool { + playerHolder.isOtherPlayerInPipPlaying + } + public let playerHolder: PlayerViewControllerHolderProtocol + internal var subscription = Set() public init( - blockID: String, - courseID: String, languages: [SubtitleUrl], - interactor: CourseInteractorProtocol, - router: CourseRouter, - appStorage: CoreStorage, - connectivity: ConnectivityProtocol + playerStateSubject: CurrentValueSubject? = nil, + connectivity: ConnectivityProtocol, + playerHolder: PlayerViewControllerHolderProtocol ) { - self.blockID = blockID - self.courseID = courseID self.languages = languages - self.interactor = interactor - self.router = router - self.appStorage = appStorage self.connectivity = connectivity + self.playerHolder = playerHolder self.prepareLanguages() + + observePlayer(with: playerStateSubject) } - @MainActor - func blockCompletionRequest() async { - do { - try await interactor.blockCompletionRequest(courseID: courseID, blockID: blockID) - NotificationCenter.default.post( - name: NSNotification.blockCompletion, - object: nil - ) - } catch let error { - if error.isInternetError || error is NoCachedDataError { - errorMessage = CoreLocalization.Error.slowOrNoInternetConnection - } else { - errorMessage = CoreLocalization.Error.unknownError + func observePlayer(with playerStateSubject: CurrentValueSubject?) { + playerStateSubject?.sink { [weak self] state in + switch state { + case .pause: + if self?.playerHolder.isPlayingInPip != true { + self?.playerHolder.playerController?.pause() + } + case .kill: + if self?.playerHolder.isPlayingInPip != true { + self?.playerHolder.playerController?.stop() + } + case .none: + break } } + .store(in: &subscription) + + playerHolder.getTimePublisher() + .sink {[weak self] time in + self?.currentTime = time + } + .store(in: &subscription) + playerHolder.getErrorPublisher() + .sink {[weak self] error in + if error.isInternetError || error is NoCachedDataError { + self?.errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } else { + self?.errorMessage = CoreLocalization.Error.unknownError + } + } + .store(in: &subscription) + playerHolder.getReadyPublisher() + .sink {[weak self] isReady in + guard isReady else { return } + self?.isLoading = false + } + .store(in: &subscription) + } @MainActor public func getSubtitles(subtitlesUrl: String) async { do { - let result = try await interactor.getSubtitles( + let result = try await playerHolder.getService().getSubtitles( url: subtitlesUrl, selectedLanguage: self.selectedLanguage ?? "en" ) + subtitles = result } catch { print(">>>>> ⛔️⛔️⛔️⛔️⛔️⛔️⛔️⛔️", error) @@ -94,6 +120,13 @@ public class VideoPlayerViewModel: ObservableObject { return locale.localizedString(forLanguageCode: code)?.capitalized ?? "" } + func pauseScrolling() { + pause = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.pause = false + } + } + private func generateLanguageItems() { items = languages.map { language in let name = generateLanguageName(code: language.language) @@ -133,7 +166,9 @@ public class VideoPlayerViewModel: ObservableObject { } func presentPicker() { - router.presentView( + let service = playerHolder.getService() + let router = service.router + service.presentView( transitionStyle: .crossDissolve, animated: true ) { diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift index 1ee73812a..2374a4f14 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayer.swift @@ -56,7 +56,7 @@ public struct YouTubeVideoPlayer: View { } } ZStack { - SubtittlesView( + SubtitlesView( languages: viewModel.languages, currentTime: $viewModel.currentTime, viewModel: viewModel, @@ -86,16 +86,10 @@ struct YouTubeVideoPlayer_Previews: PreviewProvider { static var previews: some View { YouTubeVideoPlayer( viewModel: YouTubeVideoPlayerViewModel( - url: "", - blockID: "", - courseID: "", languages: [], playerStateSubject: CurrentValueSubject(nil), - interactor: CourseInteractor(repository: CourseRepositoryMock()), - router: CourseRouterMock(), - appStorage: CoreStorageMock(), connectivity: Connectivity(), - pipManager: PipManagerProtocolMock() + playerHolder: YoutubePlayerViewControllerHolder.mock ), isOnScreen: true ) diff --git a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift index 077a1e0e3..acaacde23 100644 --- a/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/YouTubeVideoPlayerViewModel.swift @@ -13,138 +13,7 @@ import Swinject public class YouTubeVideoPlayerViewModel: VideoPlayerViewModel { - @Published var youtubePlayer: YouTubePlayer - private (set) var play = false - @Published var isLoading: Bool = true - @Published var currentTime: Double = 0 - @Published var pause: Bool = false - - private var subscription = Set() - private var duration: Double? - private var isViewedOnce: Bool = false - private var url: String - private let pipManager: PipManagerProtocol - - public init( - url: String, - blockID: String, - courseID: String, - languages: [SubtitleUrl], - playerStateSubject: CurrentValueSubject, - interactor: CourseInteractorProtocol, - router: CourseRouter, - appStorage: CoreStorage, - 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 = !pipManager.isPipActive - $0.playInline = true - $0.showFullscreenButton = true - $0.allowsPictureInPictureMediaPlayback = false - $0.showControls = true - $0.useModestBranding = false - $0.progressBarColor = .white - $0.showRelatedVideos = false - $0.showCaptions = false - $0.showAnnotations = false - $0.customUserAgent = """ - Mozilla/5.0 (iPod; U; CPU iPhone OS 4_3_3 like Mac OS X; ja-jp) - AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8J2 Safari/6533.18.5 - """ - }) - self.youtubePlayer = YouTubePlayer(source: .video(id: videoID), configuration: configuration) - self.pipManager = pipManager - super.init( - blockID: blockID, - courseID: courseID, - languages: languages, - interactor: interactor, - router: router, - appStorage: appStorage, - connectivity: connectivity - ) - - self.youtubePlayer.pause() - - subscrube(playerStateSubject: playerStateSubject) - } - - func pauseScrolling() { - pause = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - self.pause = false - } - } - - private func subscrube(playerStateSubject: CurrentValueSubject) { - playerStateSubject.sink(receiveValue: { [weak self] state in - switch state { - case .pause: - self?.youtubePlayer.stop() - case .kill, .none: - break - } - }).store(in: &subscription) - - youtubePlayer.durationPublisher.sink(receiveValue: { [weak self] duration in - 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.value - } - - if let duration = self.duration { - if (time.value / duration) >= 0.8 { - if !isViewedOnce { - Task { - await self.blockCompletionRequest() - - } - isViewedOnce = true - } - } - if (time.value / duration) >= 0.999 { - self.router.presentAppReview() - } - } - }).store(in: &subscription) - - youtubePlayer.playbackStatePublisher.sink(receiveValue: { [weak self] state in - guard let self else { return } - switch state { - case .unstarted: - self.play = false - case .ended: - self.play = false - case .playing: - self.play = true - self.pipManager.pauseCurrentPipVideo() - case .paused: - self.play = false - case .buffering, .cued: - break - } - }).store(in: &subscription) - - youtubePlayer.statePublisher.sink(receiveValue: { [weak self] state in - guard let self else { return } - if state == .ready { - self.isLoading = false - } - }).store(in: &subscription) - - pipManager.pipRatePublisher()? - .sink {[weak self] rate in - guard rate > 0 else { return } - self?.youtubePlayer.pause() - } - .store(in: &subscription) + var youtubePlayer: YouTubePlayer { + (playerHolder.playerController as? YouTubePlayer) ?? YouTubePlayer() } } diff --git a/Course/Course/Presentation/Video/YoutubePlayerViewControllerHolder.swift b/Course/Course/Presentation/Video/YoutubePlayerViewControllerHolder.swift new file mode 100644 index 000000000..e74545c25 --- /dev/null +++ b/Course/Course/Presentation/Video/YoutubePlayerViewControllerHolder.swift @@ -0,0 +1,183 @@ +// +// YoutubePlayerViewControllerHolder.swift +// Course +// +// Created by Vadim Kuznetsov on 22.04.24. +// + +import Combine +import Foundation +import YouTubePlayerKit + +public class YoutubePlayerViewControllerHolder: PlayerViewControllerHolderProtocol { + public let url: URL? + public let blockID: String + public let courseID: String + public let selectedCourseTab: Int + + public var isPlaying: Bool { + playerTracker.isPlaying + } + public var timePublisher: AnyPublisher { + playerTracker.getTimePublisher() + } + + public let isPlayingInPip: Bool = false + + public var isOtherPlayerInPipPlaying: Bool { + pipManager.isPipActive && pipManager.isPipPlaying + } + + public var duration: Double { + playerTracker.duration + } + private let playerTracker: any PlayerTrackerProtocol + private let playerService: PlayerServiceProtocol + private let videoResolution: CGSize + private let errorPublisher = PassthroughSubject() + private var isViewedOnce: Bool = false + private var cancellations: [AnyCancellable] = [] + + let pipManager: PipManagerProtocol + + public var playerController: PlayerControllerProtocol? { + playerTracker.player as? YouTubePlayer + } + + required public init( + url: URL?, + blockID: String, + courseID: String, + selectedCourseTab: Int, + videoResolution: CGSize, + pipManager: PipManagerProtocol, + playerTracker: any PlayerTrackerProtocol, + playerDelegate: PlayerDelegateProtocol?, + playerService: PlayerServiceProtocol + ) { + self.url = url + self.blockID = blockID + self.courseID = courseID + self.selectedCourseTab = selectedCourseTab + self.videoResolution = videoResolution + self.pipManager = pipManager + self.playerTracker = playerTracker + self.playerService = playerService + let youtubePlayer = playerTracker.player as? YouTubePlayer + var configuration = youtubePlayer?.configuration + configuration?.autoPlay = !pipManager.isPipActive + if let configuration = configuration { + youtubePlayer?.update(configuration: configuration) + } + addObservers() + } + + private func addObservers() { + timePublisher + .sink {[weak self] _ in + guard let strongSelf = self else { return } + if strongSelf.playerTracker.progress > 0.8 && !strongSelf.isViewedOnce { + strongSelf.isViewedOnce = true + Task { + await strongSelf.sendCompletion() + } + } + } + .store(in: &cancellations) + playerTracker.getFinishPublisher() + .sink { [weak self] in + self?.playerService.presentAppReview() + } + .store(in: &cancellations) + playerTracker.getRatePublisher() + .sink {[weak self] rate in + guard rate > 0 else { return } + self?.pausePipIfNeed() + } + .store(in: &cancellations) + pipManager.pipRatePublisher()? + .sink {[weak self] rate in + guard rate > 0, self?.isPlayingInPip == false else { return } + self?.playerController?.pause() + } + .store(in: &cancellations) + } + + public func pausePipIfNeed() { + if !isPlayingInPip { + pipManager.pauseCurrentPipVideo() + } + } + + public func getTimePublisher() -> AnyPublisher { + playerTracker.getTimePublisher() + } + + public func getErrorPublisher() -> AnyPublisher { + errorPublisher + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } + + public func getRatePublisher() -> AnyPublisher { + playerTracker.getRatePublisher() + } + + public func getReadyPublisher() -> AnyPublisher { + playerTracker.getReadyPublisher() + } + + public func getService() -> PlayerServiceProtocol { + playerService + } + + public func sendCompletion() async { + do { + try await playerService.blockCompletionRequest() + } catch { + errorPublisher.send(error) + } + } +} + +extension YoutubePlayerViewControllerHolder { + static var mock: YoutubePlayerViewControllerHolder { + YoutubePlayerViewControllerHolder( + url: URL(string: "")!, + blockID: "", + courseID: "", + selectedCourseTab: 0, + videoResolution: .zero, + pipManager: PipManagerProtocolMock(), + playerTracker: PlayerTrackerProtocolMock(url: URL(string: "")), + playerDelegate: nil, + playerService: PlayerService( + courseID: "", + blockID: "", + interactor: CourseInteractor.mock, + router: CourseRouterMock() + ) + ) + } +} + +extension YouTubePlayer: PlayerControllerProtocol { + public func play() { + self.play(completion: nil) + } + + public func pause() { + self.pause(completion: nil) + } + + public func seekTo(to date: Date) { + self.seek( + to: Measurement(value: date.secondsSinceMidnight(), unit: UnitDuration.seconds), + allowSeekAhead: true + ) + } + + public func stop() { + self.stop(completion: nil) + } +} diff --git a/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift b/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift index 2a6b2f722..a083fa577 100644 --- a/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/VideoPlayerViewModelTests.swift @@ -33,13 +33,10 @@ final class VideoPlayerViewModelTests: XCTestCase { Given(interactor, .getSubtitles(url: .any, selectedLanguage: .any, willReturn: subtitles)) - let viewModel = VideoPlayerViewModel(blockID: "", - courseID: "", - languages: [], - interactor: interactor, - router: router, - appStorage: CoreStorageMock(), - connectivity: connectivity) + let tracker = PlayerTrackerProtocolMock(url: nil) + let service = PlayerService(courseID: "", blockID: "", interactor: interactor, router: router) + let playerHolder = PlayerViewControllerHolder(url: nil, blockID: "", courseID: "", selectedCourseTab: 0, videoResolution: .zero, pipManager: PipManagerProtocolMock(), playerTracker: tracker, playerDelegate: nil, playerService: service) + let viewModel = VideoPlayerViewModel(languages: [], connectivity: connectivity, playerHolder: playerHolder) await viewModel.getSubtitles(subtitlesUrl: "url") @@ -60,13 +57,10 @@ final class VideoPlayerViewModelTests: XCTestCase { Given(connectivity, .isInternetAvaliable(getter: false)) Given(interactor, .getSubtitles(url: .any, selectedLanguage: .any, willReturn: subtitles)) - let viewModel = VideoPlayerViewModel(blockID: "", - courseID: "", - languages: [], - interactor: interactor, - router: router, - appStorage: CoreStorageMock(), - connectivity: connectivity) + let tracker = PlayerTrackerProtocolMock(url: nil) + let service = PlayerService(courseID: "", blockID: "", interactor: interactor, router: router) + let playerHolder = PlayerViewControllerHolder(url: nil, blockID: "", courseID: "", selectedCourseTab: 0, videoResolution: .zero, pipManager: PipManagerProtocolMock(), playerTracker: tracker, playerDelegate: nil, playerService: service) + let viewModel = VideoPlayerViewModel(languages: [], connectivity: connectivity, playerHolder: playerHolder) await viewModel.getSubtitles(subtitlesUrl: "url") @@ -82,14 +76,11 @@ final class VideoPlayerViewModelTests: XCTestCase { let router = CourseRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = VideoPlayerViewModel(blockID: "", - courseID: "", - languages: [], - interactor: interactor, - router: router, - appStorage: CoreStorageMock(), - connectivity: connectivity) - + let tracker = PlayerTrackerProtocolMock(url: nil) + let service = PlayerService(courseID: "", blockID: "", interactor: interactor, router: router) + let playerHolder = PlayerViewControllerHolder(url: nil, blockID: "", courseID: "", selectedCourseTab: 0, videoResolution: .zero, pipManager: PipManagerProtocolMock(), playerTracker: tracker, playerDelegate: nil, playerService: service) + let viewModel = VideoPlayerViewModel(languages: [], connectivity: connectivity, playerHolder: playerHolder) + viewModel.languages = [ SubtitleUrl(language: "en", url: "url"), SubtitleUrl(language: "uk", url: "url2") @@ -110,17 +101,13 @@ final class VideoPlayerViewModelTests: XCTestCase { let router = CourseRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = VideoPlayerViewModel(blockID: "", - courseID: "", - languages: [], - interactor: interactor, - router: router, - appStorage: CoreStorageMock(), - connectivity: connectivity) + let tracker = PlayerTrackerProtocolMock(url: nil) + let service = PlayerService(courseID: "", blockID: "", interactor: interactor, router: router) + let playerHolder = PlayerViewControllerHolder(url: nil, blockID: "", courseID: "", selectedCourseTab: 0, videoResolution: .zero, pipManager: PipManagerProtocolMock(), playerTracker: tracker, playerDelegate: nil, playerService: service) Given(interactor, .blockCompletionRequest(courseID: .any, blockID: .any, willProduce: {_ in})) - await viewModel.blockCompletionRequest() + await playerHolder.sendCompletion() Verify(interactor, .blockCompletionRequest(courseID: .any, blockID: .any)) } @@ -130,20 +117,24 @@ final class VideoPlayerViewModelTests: XCTestCase { let router = CourseRouterMock() let connectivity = ConnectivityProtocolMock() - let viewModel = VideoPlayerViewModel(blockID: "", - courseID: "", - languages: [], - interactor: interactor, - router: router, - appStorage: CoreStorageMock(), - connectivity: connectivity) + let tracker = PlayerTrackerProtocolMock(url: nil) + let service = PlayerService(courseID: "", blockID: "", interactor: interactor, router: router) + let playerHolder = PlayerViewControllerHolder(url: nil, blockID: "", courseID: "", selectedCourseTab: 0, videoResolution: .zero, pipManager: PipManagerProtocolMock(), playerTracker: tracker, playerDelegate: nil, playerService: service) + let viewModel = VideoPlayerViewModel(languages: [], connectivity: connectivity, playerHolder: playerHolder) Given(interactor, .blockCompletionRequest(courseID: .any, blockID: .any, willThrow: NSError())) - await viewModel.blockCompletionRequest() + await playerHolder.sendCompletion() Verify(interactor, .blockCompletionRequest(courseID: .any, blockID: .any)) + let expectation = XCTestExpectation(description: "Wait for combine") + + DispatchQueue.main.asyncAfter(deadline: .now()+0.1) { + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 1) + XCTAssertTrue(viewModel.showError) XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) } @@ -155,17 +146,21 @@ final class VideoPlayerViewModelTests: XCTestCase { let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) - let viewModel = VideoPlayerViewModel(blockID: "", - courseID: "", - languages: [], - interactor: interactor, - router: router, - appStorage: CoreStorageMock(), - connectivity: connectivity) + let tracker = PlayerTrackerProtocolMock(url: nil) + let service = PlayerService(courseID: "", blockID: "", interactor: interactor, router: router) + let playerHolder = PlayerViewControllerHolder(url: nil, blockID: "", courseID: "", selectedCourseTab: 0, videoResolution: .zero, pipManager: PipManagerProtocolMock(), playerTracker: tracker, playerDelegate: nil, playerService: service) + let viewModel = VideoPlayerViewModel(languages: [], connectivity: connectivity, playerHolder: playerHolder) Given(interactor, .blockCompletionRequest(courseID: .any, blockID: .any, willThrow: noInternetError)) - await viewModel.blockCompletionRequest() + await playerHolder.sendCompletion() + + let expectation = XCTestExpectation(description: "Wait for combine") + + DispatchQueue.main.asyncAfter(deadline: .now()+0.1) { + expectation.fulfill() + } + await fulfillment(of: [expectation], timeout: 1) Verify(interactor, .blockCompletionRequest(courseID: .any, blockID: .any)) diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 131cf674b..19a8768f7 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -14,6 +14,7 @@ import Dashboard import Profile import Course import Discussion +import Combine // swiftlint:disable function_body_length type_body_length class ScreenAssembly: Assembly { @@ -326,38 +327,104 @@ class ScreenAssembly: Assembly { container.register( YouTubeVideoPlayerViewModel.self - ) { r, url, blockID, courseID, languages, playerStateSubject in - YouTubeVideoPlayerViewModel( - url: url, - blockID: blockID, - courseID: courseID, + ) { (r, url: URL?, blockID: String, courseID: String, languages, playerStateSubject) in + let router: Router = r.resolve(Router.self)! + return YouTubeVideoPlayerViewModel( languages: languages, playerStateSubject: playerStateSubject, - interactor: r.resolve(CourseInteractorProtocol.self)!, - router: r.resolve(CourseRouter.self)!, - appStorage: r.resolve(CoreStorage.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, - pipManager: r.resolve(PipManagerProtocol.self)! + playerHolder: r.resolve( + YoutubePlayerViewControllerHolder.self, + arguments: url, + blockID, + courseID, + router.currentCourseTabSelection + )! ) } - container.register( - EncodedVideoPlayerViewModel.self - ) { r, url, blockID, courseID, languages, playerStateSubject in + container.register(EncodedVideoPlayerViewModel.self) { (r, url: URL?, blockID: String, courseID: String, languages: [SubtitleUrl], playerStateSubject: CurrentValueSubject) in let router: Router = r.resolve(Router.self)! + + let holder = r.resolve( + PlayerViewControllerHolder.self, + arguments: url, + blockID, + courseID, + router.currentCourseTabSelection + )! return EncodedVideoPlayerViewModel( - url: url, - blockID: blockID, - courseID: courseID, languages: languages, playerStateSubject: playerStateSubject, - interactor: r.resolve(CourseInteractorProtocol.self)!, - router: r.resolve(CourseRouter.self)!, - appStorage: r.resolve(CoreStorage.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, + playerHolder: holder + ) + } + + container.register(PlayerDelegateProtocol.self) { _, manager in + PlayerDelegate(pipManager: manager) + } + + container.register(YoutubePlayerTracker.self) { (_, url) in + YoutubePlayerTracker(url: url) + } + + container.register(PlayerTracker.self) { (_, url) in + PlayerTracker(url: url) + } + + container.register( + YoutubePlayerViewControllerHolder.self + ) { r, url, blockID, courseID, selectedCourseTab in + YoutubePlayerViewControllerHolder( + url: url, + blockID: blockID, + courseID: courseID, + selectedCourseTab: selectedCourseTab, + videoResolution: .zero, pipManager: r.resolve(PipManagerProtocol.self)!, - selectedCourseTab: router.currentCourseTabSelection + playerTracker: r.resolve(YoutubePlayerTracker.self, argument: url)!, + playerDelegate: nil, + playerService: r.resolve(PlayerServiceProtocol.self, arguments: courseID, blockID)! + ) + } + + container.register( + PlayerViewControllerHolder.self + ) { (r, url: URL?, blockID: String, courseID: String, selectedCourseTab: Int) in + let pipManager = r.resolve(PipManagerProtocol.self)! + if let holder = pipManager.holder( + for: url, + blockID: blockID, + courseID: courseID, + selectedCourseTab: selectedCourseTab + ) as? PlayerViewControllerHolder { + return holder + } + + let storage = r.resolve(CoreStorage.self)! + let quality = storage.userSettings?.streamingQuality ?? .auto + let tracker = r.resolve(PlayerTracker.self, argument: url)! + let delegate = r.resolve(PlayerDelegateProtocol.self, argument: pipManager)! + let holder = PlayerViewControllerHolder( + url: url, + blockID: blockID, + courseID: courseID, + selectedCourseTab: selectedCourseTab, + videoResolution: quality.resolution, + pipManager: pipManager, + playerTracker: tracker, + playerDelegate: delegate, + playerService: r.resolve(PlayerServiceProtocol.self, arguments: courseID, blockID)! ) + delegate.playerHolder = holder + return holder + } + + container.register(PlayerServiceProtocol.self) { r, courseID, blockID in + let interactor = r.resolve(CourseInteractorProtocol.self)! + let router = r.resolve(CourseRouter.self)! + return PlayerService(courseID: courseID, blockID: blockID, interactor: interactor, router: router) } container.register(HandoutsViewModel.self) { r, courseID in diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift index 8720ae03f..636c4d101 100644 --- a/OpenEdX/Managers/PipManager.swift +++ b/OpenEdX/Managers/PipManager.swift @@ -11,7 +11,7 @@ import Discovery import SwiftUI public class PipManager: PipManagerProtocol { - var controllerHolder: PlayerViewControllerHolder? + var controllerHolder: PlayerViewControllerHolderProtocol? let discoveryInteractor: DiscoveryInteractorProtocol let courseInteractor: CourseInteractorProtocol let router: Router @@ -19,10 +19,10 @@ public class PipManager: PipManagerProtocol { public var isPipActive: Bool { controllerHolder != nil } - - private var ratePublisher: PassthroughSubject? - private var cancellations: [AnyCancellable] = [] - + public var isPipPlaying: Bool { + controllerHolder?.isPlaying ?? false + } + public init( router: Router, discoveryInteractor: DiscoveryInteractorProtocol, @@ -40,7 +40,7 @@ public class PipManager: PipManagerProtocol { blockID: String, courseID: String, selectedCourseTab: Int - ) -> PlayerViewControllerHolder? { + ) -> PlayerViewControllerHolderProtocol? { if controllerHolder?.blockID == blockID, controllerHolder?.courseID == courseID, controllerHolder?.selectedCourseTab == selectedCourseTab { @@ -50,32 +50,29 @@ public class PipManager: PipManagerProtocol { return nil } - public func set(holder: PlayerViewControllerHolder) { + public func set(holder: PlayerViewControllerHolderProtocol) { controllerHolder = holder - 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 { + public func remove(holder: PlayerViewControllerHolderProtocol) { + if isCurrentHolderEqualTo(holder) { controllerHolder = nil - cancellations.removeAll() - ratePublisher = nil } } + + private func isCurrentHolderEqualTo(_ holder: PlayerViewControllerHolderProtocol) -> Bool { + controllerHolder?.blockID == holder.blockID && + controllerHolder?.courseID == holder.courseID && + controllerHolder?.url == holder.url && + controllerHolder?.selectedCourseTab == holder.selectedCourseTab + } public func pipRatePublisher() -> AnyPublisher? { - ratePublisher? - .eraseToAnyPublisher() + controllerHolder?.getRatePublisher() } @MainActor - public func restore(holder: PlayerViewControllerHolder) async throws { + public func restore(holder: PlayerViewControllerHolderProtocol) async throws { let courseID = holder.courseID // if we are on CourseUnitView, and tab is same with holder @@ -94,11 +91,11 @@ public class PipManager: PipManagerProtocol { public func pauseCurrentPipVideo() { guard let holder = controllerHolder else { return } - holder.playerController.player?.pause() + holder.playerController?.pause() } @MainActor - private func navigate(to holder: PlayerViewControllerHolder) async throws { + private func navigate(to holder: PlayerViewControllerHolderProtocol) async throws { let currentControllers = router.getNavigationController().viewControllers guard let mainController = currentControllers.first as? UIHostingController else { return @@ -127,7 +124,7 @@ public class PipManager: PipManagerProtocol { @MainActor private func courseVerticalController( - for holder: PlayerViewControllerHolder + for holder: PlayerViewControllerHolderProtocol ) async throws -> UIHostingController { var courseStructure = try await courseInteractor.getLoadedCourseBlocks(courseID: holder.courseID) if holder.selectedCourseTab == CourseTab.videos.rawValue { @@ -150,7 +147,7 @@ public class PipManager: PipManagerProtocol { @MainActor private func courseUnitController( - for holder: PlayerViewControllerHolder + for holder: PlayerViewControllerHolderProtocol ) async throws -> UIHostingController { var courseStructure = try await courseInteractor.getLoadedCourseBlocks(courseID: holder.courseID) @@ -178,7 +175,7 @@ public class PipManager: PipManagerProtocol { @MainActor private func containerController( - for holder: PlayerViewControllerHolder + for holder: PlayerViewControllerHolderProtocol ) async throws -> UIHostingController { let courseDetails = try await getCourseDetails(for: holder) let isActive: Bool? = nil @@ -195,7 +192,7 @@ public class PipManager: PipManagerProtocol { return controller } - private func getCourseDetails(for holder: PlayerViewControllerHolder) async throws -> CourseDetails { + private func getCourseDetails(for holder: PlayerViewControllerHolderProtocol) async throws -> CourseDetails { if let value = try? await discoveryInteractor.getLoadedCourseDetails( courseID: holder.courseID ) { From 4690b1b0673b9335b294f083f6cb6a9cd38ffd4c Mon Sep 17 00:00:00 2001 From: RawanMatar89 <41669180+RawanMatar89@users.noreply.github.com> Date: Wed, 22 May 2024 10:26:19 +0300 Subject: [PATCH 08/55] fix: rtl for "arrowLeft" image in Core/Assets (#440) (cherry picked from commit daac035ad38c4f15547b0af62ff8462e27016dfa) --- Core/Core/Assets.xcassets/arrowLeft.imageset/Contents.json | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Core/Core/Assets.xcassets/arrowLeft.imageset/Contents.json b/Core/Core/Assets.xcassets/arrowLeft.imageset/Contents.json index cfa90a49f..117428b1d 100644 --- a/Core/Core/Assets.xcassets/arrowLeft.imageset/Contents.json +++ b/Core/Core/Assets.xcassets/arrowLeft.imageset/Contents.json @@ -2,7 +2,8 @@ "images" : [ { "filename" : "icon left.svg", - "idiom" : "universal" + "idiom" : "universal", + "language-direction" : "left-to-right" }, { "appearances" : [ @@ -12,7 +13,8 @@ } ], "filename" : "icon left-2.svg", - "idiom" : "universal" + "idiom" : "universal", + "language-direction" : "left-to-right" } ], "info" : { From a17038f4d9583220263b699590cbbfbf01ca8a2f Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Wed, 29 May 2024 15:53:20 +0300 Subject: [PATCH 09/55] feat: [FC-0047] calendar synchronization design (#439) * feat: calendar synchronization design * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: update profile mock file * fix: address feedback * fix: address feedback remove old errorAlertView variables --- Core/Core.xcodeproj/project.pbxproj | 12 + .../Assets.xcassets/Calendar/Contents.json | 6 + .../calendarAccess.imageset/Contents.json | 12 + .../calendarAccess.svg | 10 + .../syncFailed.imageset/Contents.json | 12 + .../syncFailed.imageset/syncFailed.svg | 3 + .../syncOffline.imageset/Contents.json | 12 + .../syncOffline.imageset/syncOffline.svg | 3 + .../Calendar/synced.imageset/Contents.json | 12 + .../Calendar/synced.imageset/synced.svg | 3 + .../calendarSyncIcon.imageset/Contents.json | 15 ++ .../calendarSyncIcon.svg | 4 + Core/Core/Extensions/Bundle.swift | 15 ++ Core/Core/Extensions/ViewExtension.swift | 16 ++ Core/Core/SwiftGen/Assets.swift | 5 + Core/Core/View/Base/CheckBoxView.swift | 13 +- Core/Core/View/Base/ErrorAlertView.swift | 35 +++ Core/Core/View/Base/NavigationTitle.swift | 52 ++++ Core/Core/View/Base/StyledButton.swift | 6 +- OpenEdX/DI/ScreenAssembly.swift | 6 + OpenEdX/Router.swift | 21 ++ Profile/Profile.xcodeproj/project.pbxproj | 56 +++++ .../DatesAndCalendar/CoursesToSyncView.swift | 134 ++++++++++ .../DatesAndCalendarView.swift | 195 +++++++++++++++ .../DatesAndCalendarViewModel.swift | 131 ++++++++++ .../Elements/AssignmentStatusView.swift | 90 +++++++ .../Elements/CalendarDialogView.swift | 187 ++++++++++++++ .../Elements/DropDownPicker.swift | 184 ++++++++++++++ .../Elements/NewCalendarView.swift | 154 ++++++++++++ .../Elements/SyncSelector.swift | 60 +++++ .../Elements/ToggleWithDescriptionView.swift | 98 ++++++++ .../SyncCalendarOptionsView.swift | 231 ++++++++++++++++++ .../Presentation/Profile/ProfileView.swift | 2 +- .../Profile/Presentation/ProfileRouter.swift | 12 + .../Presentation/Settings/SettingsView.swift | 29 +++ Profile/Profile/SwiftGen/Strings.swift | 132 ++++++++++ Profile/Profile/en.lproj/Localizable.strings | 65 +++++ Profile/Profile/uk.lproj/Localizable.strings | 63 +++++ .../ProfileTests/ProfileMock.generated.swift | 45 ++++ 39 files changed, 2136 insertions(+), 5 deletions(-) create mode 100644 Core/Core/Assets.xcassets/Calendar/Contents.json create mode 100644 Core/Core/Assets.xcassets/Calendar/calendarAccess.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/Calendar/calendarAccess.imageset/calendarAccess.svg create mode 100644 Core/Core/Assets.xcassets/Calendar/syncFailed.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/Calendar/syncFailed.imageset/syncFailed.svg create mode 100644 Core/Core/Assets.xcassets/Calendar/syncOffline.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/Calendar/syncOffline.imageset/syncOffline.svg create mode 100644 Core/Core/Assets.xcassets/Calendar/synced.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/Calendar/synced.imageset/synced.svg create mode 100644 Core/Core/Assets.xcassets/calendarSyncIcon.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/calendarSyncIcon.imageset/calendarSyncIcon.svg create mode 100644 Core/Core/Extensions/Bundle.swift create mode 100644 Core/Core/View/Base/ErrorAlertView.swift create mode 100644 Core/Core/View/Base/NavigationTitle.swift create mode 100644 Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift create mode 100644 Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift create mode 100644 Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift create mode 100644 Profile/Profile/Presentation/DatesAndCalendar/Elements/AssignmentStatusView.swift create mode 100644 Profile/Profile/Presentation/DatesAndCalendar/Elements/CalendarDialogView.swift create mode 100644 Profile/Profile/Presentation/DatesAndCalendar/Elements/DropDownPicker.swift create mode 100644 Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift create mode 100644 Profile/Profile/Presentation/DatesAndCalendar/Elements/SyncSelector.swift create mode 100644 Profile/Profile/Presentation/DatesAndCalendar/Elements/ToggleWithDescriptionView.swift create mode 100644 Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index f718ae857..25f7353d2 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ 0254D1912BCD699F000CDE89 /* RefreshProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0254D1902BCD699F000CDE89 /* RefreshProgressView.swift */; }; 0255D5582936283A004DBC1A /* UploadBodyEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0255D55729362839004DBC1A /* UploadBodyEncoding.swift */; }; 0259104A29C4A5B6004B5A55 /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0259104929C4A5B6004B5A55 /* UserSettings.swift */; }; + 025A20502C071EB0003EA08D /* ErrorAlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025A204F2C071EB0003EA08D /* ErrorAlertView.swift */; }; 025B36752A13B7D5001A640E /* UnitButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025B36742A13B7D5001A640E /* UnitButtonView.swift */; }; 025EF2F62971740000B838AB /* YouTubePlayerKit in Frameworks */ = {isa = PBXBuildFile; productRef = 025EF2F52971740000B838AB /* YouTubePlayerKit */; }; 0260E58028FD792800BBBE18 /* WebUnitViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0260E57F28FD792800BBBE18 /* WebUnitViewModel.swift */; }; @@ -56,6 +57,7 @@ 027BD3BD2909478B00392132 /* UIView+EnclosingScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3BA2909478B00392132 /* UIView+EnclosingScrollView.swift */; }; 027BD3BE2909478B00392132 /* UIResponder+CurrentResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3BB2909478B00392132 /* UIResponder+CurrentResponder.swift */; }; 027BD3C52909707700392132 /* Shake.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3C42909707700392132 /* Shake.swift */; }; + 027F1BF72C071C820001A24C /* NavigationTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027F1BF62C071C820001A24C /* NavigationTitle.swift */; }; 0282DA7328F98CC9003C3F07 /* WebUnitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0282DA7228F98CC9003C3F07 /* WebUnitView.swift */; }; 0283347D28D4D3DE00C828FC /* Data_Discovery.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0283347C28D4D3DE00C828FC /* Data_Discovery.swift */; }; 0283348028D4DCD200C828FC /* ViewExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0283347F28D4DCD200C828FC /* ViewExtension.swift */; }; @@ -64,6 +66,7 @@ 028F9F37293A44C700DE65D0 /* Data_ResetPassword.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028F9F36293A44C700DE65D0 /* Data_ResetPassword.swift */; }; 028F9F39293A452B00DE65D0 /* ResetPassword.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028F9F38293A452B00DE65D0 /* ResetPassword.swift */; }; 0295C885299B99DD00ABE571 /* RefreshableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0295C884299B99DD00ABE571 /* RefreshableScrollView.swift */; }; + 029EE3ED2BF6650500F64F33 /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029EE3EC2BF6650500F64F33 /* Bundle.swift */; }; 02A463112AEA966C00331037 /* AppReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A463102AEA966C00331037 /* AppReviewView.swift */; }; 02A4833529B8A73400D33F33 /* CorePersistenceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833429B8A73400D33F33 /* CorePersistenceProtocol.swift */; }; 02A4833829B8A8F900D33F33 /* CoreDataModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833629B8A8F800D33F33 /* CoreDataModel.xcdatamodeld */; }; @@ -221,6 +224,7 @@ 0254D1902BCD699F000CDE89 /* RefreshProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshProgressView.swift; sourceTree = ""; }; 0255D55729362839004DBC1A /* UploadBodyEncoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadBodyEncoding.swift; sourceTree = ""; }; 0259104929C4A5B6004B5A55 /* UserSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettings.swift; sourceTree = ""; }; + 025A204F2C071EB0003EA08D /* ErrorAlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorAlertView.swift; sourceTree = ""; }; 025B36742A13B7D5001A640E /* UnitButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitButtonView.swift; sourceTree = ""; }; 0260E57F28FD792800BBBE18 /* WebUnitViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebUnitViewModel.swift; sourceTree = ""; }; 027BD3912907D88F00392132 /* Data_RegistrationFields.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_RegistrationFields.swift; sourceTree = ""; }; @@ -238,6 +242,7 @@ 027BD3BA2909478B00392132 /* UIView+EnclosingScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+EnclosingScrollView.swift"; sourceTree = ""; }; 027BD3BB2909478B00392132 /* UIResponder+CurrentResponder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIResponder+CurrentResponder.swift"; sourceTree = ""; }; 027BD3C42909707700392132 /* Shake.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shake.swift; sourceTree = ""; }; + 027F1BF62C071C820001A24C /* NavigationTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationTitle.swift; sourceTree = ""; }; 0282DA7228F98CC9003C3F07 /* WebUnitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebUnitView.swift; sourceTree = ""; }; 0283347C28D4D3DE00C828FC /* Data_Discovery.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_Discovery.swift; sourceTree = ""; }; 0283347F28D4DCD200C828FC /* ViewExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewExtension.swift; sourceTree = ""; }; @@ -246,6 +251,7 @@ 028F9F36293A44C700DE65D0 /* Data_ResetPassword.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_ResetPassword.swift; sourceTree = ""; }; 028F9F38293A452B00DE65D0 /* ResetPassword.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetPassword.swift; sourceTree = ""; }; 0295C884299B99DD00ABE571 /* RefreshableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableScrollView.swift; sourceTree = ""; }; + 029EE3EC2BF6650500F64F33 /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = ""; }; 02A463102AEA966C00331037 /* AppReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReviewView.swift; sourceTree = ""; }; 02A4833429B8A73400D33F33 /* CorePersistenceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorePersistenceProtocol.swift; sourceTree = ""; }; 02A4833729B8A8F800D33F33 /* CoreDataModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CoreDataModel.xcdatamodel; sourceTree = ""; }; @@ -486,6 +492,7 @@ 02D400602B0678190029D168 /* SKStoreReviewControllerExtension.swift */, E0D5861B2B2FF85B009B4BA7 /* RawStringExtactable.swift */, BA981BCD2B8F5C49005707C2 /* Sequence+Extensions.swift */, + 029EE3EC2BF6650500F64F33 /* Bundle.swift */, ); path = Extensions; sourceTree = ""; @@ -711,6 +718,8 @@ 025B36742A13B7D5001A640E /* UnitButtonView.swift */, 0727877C28D25212002E9142 /* ProgressBar.swift */, 022C64E329AE0191000F532B /* TextWithUrls.swift */, + 027F1BF62C071C820001A24C /* NavigationTitle.swift */, + 025A204F2C071EB0003EA08D /* ErrorAlertView.swift */, 0727878028D25EFD002E9142 /* SnackBarView.swift */, 02F6EF3A28D9B8EC00835477 /* CourseCellView.swift */, 024FCCFF28EF1CD300232339 /* WebBrowser.swift */, @@ -1058,6 +1067,7 @@ 02F6EF4A28D9F0A700835477 /* DateExtension.swift in Sources */, DBF6F2462B01DAFE0098414B /* AgreementConfig.swift in Sources */, 027BD3AF2909475000392132 /* DismissKeyboardTapHandler.swift in Sources */, + 027F1BF72C071C820001A24C /* NavigationTitle.swift in Sources */, 06619EAA2B8F2936001FAADE /* ReadabilityModifier.swift in Sources */, BAFB99902B14B377007D09F9 /* GoogleConfig.swift in Sources */, 02E225B0291D29EB0067769A /* UrlExtension.swift in Sources */, @@ -1128,6 +1138,7 @@ 023A4DD4299E66BD006C0E48 /* OfflineSnackBarView.swift in Sources */, 021D925728DCF12900ACC565 /* AlertView.swift in Sources */, 027BD3A82909474200392132 /* KeyboardAvoidingViewController.swift in Sources */, + 025A20502C071EB0003EA08D /* ErrorAlertView.swift in Sources */, 02E93F852AEBAEBC006C4750 /* AppReviewViewModel.swift in Sources */, A595689B2B6173DF00ED4F90 /* BranchConfig.swift in Sources */, 0770DE2528D08FBA006D8A5D /* CoreStorage.swift in Sources */, @@ -1192,6 +1203,7 @@ 0231CDBE2922422D00032416 /* CSSInjector.swift in Sources */, 064987982B4D69FF0071642A /* CSSInjectionProtocol.swift in Sources */, BAAD62C62AFCF00B000E6103 /* CustomDisclosureGroup.swift in Sources */, + 029EE3ED2BF6650500F64F33 /* Bundle.swift in Sources */, BADB3F5B2AD6EC56004D5CFA /* ResultExtension.swift in Sources */, 0236961928F9A26900EEF206 /* AuthRepository.swift in Sources */, 023A1136291432B200D0D354 /* RegistrationTextField.swift in Sources */, diff --git a/Core/Core/Assets.xcassets/Calendar/Contents.json b/Core/Core/Assets.xcassets/Calendar/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Core/Core/Assets.xcassets/Calendar/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/Calendar/calendarAccess.imageset/Contents.json b/Core/Core/Assets.xcassets/Calendar/calendarAccess.imageset/Contents.json new file mode 100644 index 000000000..b78b96492 --- /dev/null +++ b/Core/Core/Assets.xcassets/Calendar/calendarAccess.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "calendarAccess.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/Calendar/calendarAccess.imageset/calendarAccess.svg b/Core/Core/Assets.xcassets/Calendar/calendarAccess.imageset/calendarAccess.svg new file mode 100644 index 000000000..d80a2356a --- /dev/null +++ b/Core/Core/Assets.xcassets/Calendar/calendarAccess.imageset/calendarAccess.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Core/Core/Assets.xcassets/Calendar/syncFailed.imageset/Contents.json b/Core/Core/Assets.xcassets/Calendar/syncFailed.imageset/Contents.json new file mode 100644 index 000000000..6ff388e4c --- /dev/null +++ b/Core/Core/Assets.xcassets/Calendar/syncFailed.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "syncFailed.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/Calendar/syncFailed.imageset/syncFailed.svg b/Core/Core/Assets.xcassets/Calendar/syncFailed.imageset/syncFailed.svg new file mode 100644 index 000000000..fe6e39f14 --- /dev/null +++ b/Core/Core/Assets.xcassets/Calendar/syncFailed.imageset/syncFailed.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/Calendar/syncOffline.imageset/Contents.json b/Core/Core/Assets.xcassets/Calendar/syncOffline.imageset/Contents.json new file mode 100644 index 000000000..1a75410c4 --- /dev/null +++ b/Core/Core/Assets.xcassets/Calendar/syncOffline.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "syncOffline.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/Calendar/syncOffline.imageset/syncOffline.svg b/Core/Core/Assets.xcassets/Calendar/syncOffline.imageset/syncOffline.svg new file mode 100644 index 000000000..6c7bec7f2 --- /dev/null +++ b/Core/Core/Assets.xcassets/Calendar/syncOffline.imageset/syncOffline.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/Calendar/synced.imageset/Contents.json b/Core/Core/Assets.xcassets/Calendar/synced.imageset/Contents.json new file mode 100644 index 000000000..5d1255461 --- /dev/null +++ b/Core/Core/Assets.xcassets/Calendar/synced.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "synced.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/Calendar/synced.imageset/synced.svg b/Core/Core/Assets.xcassets/Calendar/synced.imageset/synced.svg new file mode 100644 index 000000000..71652aafd --- /dev/null +++ b/Core/Core/Assets.xcassets/Calendar/synced.imageset/synced.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/calendarSyncIcon.imageset/Contents.json b/Core/Core/Assets.xcassets/calendarSyncIcon.imageset/Contents.json new file mode 100644 index 000000000..d75d6b0b4 --- /dev/null +++ b/Core/Core/Assets.xcassets/calendarSyncIcon.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "calendarSyncIcon.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Core/Core/Assets.xcassets/calendarSyncIcon.imageset/calendarSyncIcon.svg b/Core/Core/Assets.xcassets/calendarSyncIcon.imageset/calendarSyncIcon.svg new file mode 100644 index 000000000..7708fa304 --- /dev/null +++ b/Core/Core/Assets.xcassets/calendarSyncIcon.imageset/calendarSyncIcon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/Core/Core/Extensions/Bundle.swift b/Core/Core/Extensions/Bundle.swift new file mode 100644 index 000000000..b46037f19 --- /dev/null +++ b/Core/Core/Extensions/Bundle.swift @@ -0,0 +1,15 @@ +// +// Bundle.swift +// Core +// +// Created by  Stepanok Ivan on 16.05.2024. +// + +import Foundation + +public extension Bundle { + var applicationName: String? { + object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? + object(forInfoDictionaryKey: "CFBundleName") as? String + } +} diff --git a/Core/Core/Extensions/ViewExtension.swift b/Core/Core/Extensions/ViewExtension.swift index 71392ebd7..e791925ba 100644 --- a/Core/Core/Extensions/ViewExtension.swift +++ b/Core/Core/Extensions/ViewExtension.swift @@ -237,6 +237,22 @@ public extension View { .padding(.horizontal, 8) .offset(y: topPadding) } + + @ViewBuilder + private func onTapBackgroundContent(enabled: Bool, _ action: @escaping () -> Void) -> some View { + if enabled { + Color.clear + .frame(width: UIScreen.main.bounds.width * 2, height: UIScreen.main.bounds.height * 2) + .contentShape(Rectangle()) + .onTapGesture(perform: action) + } + } + + func onTapBackground(enabled: Bool, _ action: @escaping () -> Void) -> some View { + background( + onTapBackgroundContent(enabled: enabled, action) + ) + } } public extension View { diff --git a/Core/Core/SwiftGen/Assets.swift b/Core/Core/SwiftGen/Assets.swift index a6cb7b057..dfa0c9677 100644 --- a/Core/Core/SwiftGen/Assets.swift +++ b/Core/Core/SwiftGen/Assets.swift @@ -24,6 +24,10 @@ public typealias AssetImageTypeAlias = ImageAsset.Image // swiftlint:disable identifier_name line_length nesting type_body_length type_name public enum CoreAssets { + public static let calendarAccess = ImageAsset(name: "calendarAccess") + public static let syncFailed = ImageAsset(name: "syncFailed") + public static let syncOffline = ImageAsset(name: "syncOffline") + public static let synced = ImageAsset(name: "synced") public static let appleButtonColor = ColorAsset(name: "AppleButtonColor") public static let facebookButtonColor = ColorAsset(name: "FacebookButtonColor") public static let googleButtonColor = ColorAsset(name: "GoogleButtonColor") @@ -93,6 +97,7 @@ public enum CoreAssets { public static let alarm = ImageAsset(name: "alarm") public static let arrowLeft = ImageAsset(name: "arrowLeft") public static let arrowRight16 = ImageAsset(name: "arrowRight16") + public static let calendarSyncIcon = ImageAsset(name: "calendarSyncIcon") public static let certificate = ImageAsset(name: "certificate") public static let certificateBadge = ImageAsset(name: "certificateBadge") public static let check = ImageAsset(name: "check") diff --git a/Core/Core/View/Base/CheckBoxView.swift b/Core/Core/View/Base/CheckBoxView.swift index efed96ccc..267d21463 100644 --- a/Core/Core/View/Base/CheckBoxView.swift +++ b/Core/Core/View/Base/CheckBoxView.swift @@ -13,11 +13,18 @@ public struct CheckBoxView: View { @Binding private var checked: Bool private var text: String private var font: Font + private let color: Color - public init(checked: Binding, text: String, font: Font = Theme.Fonts.labelLarge) { + public init( + checked: Binding, + text: String, + font: Font = Theme.Fonts.labelLarge, + color: Color = Theme.Colors.textPrimary + ) { self._checked = checked self.text = text self.font = font + self.color = color } public var body: some View { @@ -26,11 +33,11 @@ public struct CheckBoxView: View { systemName: checked ? "checkmark.square.fill" : "square" ) .foregroundColor( - checked ? Theme.Colors.accentXColor : Theme.Colors.textPrimary + checked ? Theme.Colors.accentXColor : color ) Text(text) .font(font) - .foregroundColor(Theme.Colors.textPrimary) + .foregroundColor(color) } .onTapGesture { withAnimation(.linear(duration: 0.1)) { diff --git a/Core/Core/View/Base/ErrorAlertView.swift b/Core/Core/View/Base/ErrorAlertView.swift new file mode 100644 index 000000000..5256f0f7f --- /dev/null +++ b/Core/Core/View/Base/ErrorAlertView.swift @@ -0,0 +1,35 @@ +// +// ErrorAlertView.swift +// Core +// +// Created by  Stepanok Ivan on 29.05.2024. +// + +import SwiftUI +import Theme + +public struct ErrorAlertView: View { + + @Binding var errorMessage: String? + + public init(errorMessage: Binding) { + self._errorMessage = errorMessage + } + + public var body: some View { + VStack { + Spacer() + SnackBarView(message: errorMessage) + .transition(.move(edge: .bottom)) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + Theme.Timeout.snackbarMessageLongTimeout) { + errorMessage = nil + } + } + } + } +} + +#Preview { + ErrorAlertView(errorMessage: .constant("Error message")) +} diff --git a/Core/Core/View/Base/NavigationTitle.swift b/Core/Core/View/Base/NavigationTitle.swift new file mode 100644 index 000000000..7cae61193 --- /dev/null +++ b/Core/Core/View/Base/NavigationTitle.swift @@ -0,0 +1,52 @@ +// +// NavigationTitle.swift +// Core +// +// Created by  Stepanok Ivan on 29.05.2024. +// + +import SwiftUI +import Theme + +public struct NavigationTitle: View { + + private let title: String + private let backAction: () -> Void + + @Environment(\.isHorizontal) private var isHorizontal + + public init(title: String, backAction: @escaping () -> Void) { + self.title = title + self.backAction = backAction + } + + public var body: some View { + // MARK: - Navigation and Title + ZStack { + HStack { + Text(title) + .titleSettings(color: Theme.Colors.loginNavigationText) + .accessibilityIdentifier("\(title)_text") + } + VStack { + BackNavigationButton( + color: Theme.Colors.loginNavigationText, + action: { + backAction() + } + ) + .backViewStyle() + .foregroundColor(Theme.Colors.styledButtonText) + .padding(.leading, isHorizontal ? 48 : 0) + .accessibilityIdentifier("back_button") + + }.frame(minWidth: 0, + maxWidth: .infinity, + alignment: .topLeading) + } + } +} + +#Preview { + NavigationTitle(title: "Title", backAction: {}) +} diff --git a/Core/Core/View/Base/StyledButton.swift b/Core/Core/View/Base/StyledButton.swift index f76252e44..6aa2962f4 100644 --- a/Core/Core/View/Base/StyledButton.swift +++ b/Core/Core/View/Base/StyledButton.swift @@ -22,6 +22,7 @@ public struct StyledButton: View { private let buttonColor: Color private let textColor: Color private let isActive: Bool + private let horizontalPadding: Bool private let borderColor: Color private let iconImage: Image? private let iconPosition: IconImagePosition @@ -34,7 +35,8 @@ public struct StyledButton: View { borderColor: Color = .clear, iconImage: Image? = nil, iconPosition: IconImagePosition = .none, - isActive: Bool = true) { + isActive: Bool = true, + horizontalPadding: Bool = false) { self.title = title self.action = action self.isTransparent = isTransparent @@ -44,6 +46,7 @@ public struct StyledButton: View { self.isActive = isActive self.iconImage = iconImage self.iconPosition = iconPosition + self.horizontalPadding = horizontalPadding } public var body: some View { @@ -69,6 +72,7 @@ public struct StyledButton: View { } Spacer() } + .padding(.horizontal, horizontalPadding ? 20 : 0) } .disabled(!isActive) .frame(maxWidth: idiom == .pad ? 260: .infinity, minHeight: isTransparent ? 36 : 42) diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 19a8768f7..3ef1aac7a 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -216,6 +216,12 @@ class ScreenAssembly: Assembly { ) } + container.register(DatesAndCalendarViewModel.self) { r in + DatesAndCalendarViewModel( + router: r.resolve(ProfileRouter.self)! + ) + } + container.register(ManageAccountViewModel.self) { r in ManageAccountViewModel( router: r.resolve(ProfileRouter.self)!, diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index cd0bd192e..a6e175dff 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -714,6 +714,27 @@ public class Router: AuthorizationRouter, navigationController.pushViewController(controller, animated: true) } + public func showDatesAndCalendar() { + let viewModel = Container.shared.resolve(DatesAndCalendarViewModel.self)! + let view = DatesAndCalendarView(viewModel: viewModel) + let controller = UIHostingController(rootView: view) + navigationController.pushViewController(controller, animated: true) + } + + public func showSyncCalendarOptions() { + let viewModel = Container.shared.resolve(DatesAndCalendarViewModel.self)! + let view = SyncCalendarOptionsView(viewModel: viewModel) + let controller = UIHostingController(rootView: view) + navigationController.pushViewController(controller, animated: true) + } + + public func showCoursesToSync() { + let viewModel = Container.shared.resolve(DatesAndCalendarViewModel.self)! + let view = CoursesToSyncView(viewModel: viewModel) + let controller = UIHostingController(rootView: view) + navigationController.pushViewController(controller, animated: true) + } + public func showManageAccount() { let viewModel = Container.shared.resolve(ManageAccountViewModel.self)! let view = ManageAccountView(viewModel: viewModel) diff --git a/Profile/Profile.xcodeproj/project.pbxproj b/Profile/Profile.xcodeproj/project.pbxproj index a9a09fc33..a0f53c75d 100644 --- a/Profile/Profile.xcodeproj/project.pbxproj +++ b/Profile/Profile.xcodeproj/project.pbxproj @@ -11,6 +11,8 @@ 020306C82932B13F000949EA /* EditProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 020306C72932B13F000949EA /* EditProfileView.swift */; }; 020306CA2932B14D000949EA /* EditProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 020306C92932B14D000949EA /* EditProfileViewModel.swift */; }; 020CBD8D2BC53E1B003D6B4E /* VideoSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 020CBD8C2BC53E1B003D6B4E /* VideoSettingsView.swift */; }; + 021C90D52BC986B3004876AF /* DatesAndCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021C90D42BC986B3004876AF /* DatesAndCalendarView.swift */; }; + 021C90D72BC98734004876AF /* DatesAndCalendarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021C90D62BC98734004876AF /* DatesAndCalendarViewModel.swift */; }; 021D924628DC634300ACC565 /* ProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D924528DC634300ACC565 /* ProfileView.swift */; }; 021D924C28DC884A00ACC565 /* ProfileEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D924B28DC884A00ACC565 /* ProfileEndpoint.swift */; }; 021D924E28DC88BB00ACC565 /* ProfileRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D924D28DC88BB00ACC565 /* ProfileRepository.swift */; }; @@ -18,6 +20,9 @@ 021D925528DC92F800ACC565 /* ProfileInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D925428DC92F800ACC565 /* ProfileInteractor.swift */; }; 021D925C28DDADBD00ACC565 /* swiftgen.yml in Resources */ = {isa = PBXBuildFile; fileRef = 021D925B28DDADBD00ACC565 /* swiftgen.yml */; }; 021D925F28DDADE600ACC565 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 021D926128DDADE600ACC565 /* Localizable.strings */; }; + 022301E42BF4B7610028A287 /* SyncCalendarOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022301E32BF4B7610028A287 /* SyncCalendarOptionsView.swift */; }; + 022301E62BF4B7A20028A287 /* AssignmentStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022301E52BF4B7A20028A287 /* AssignmentStatusView.swift */; }; + 022301E82BF4C4E70028A287 /* ToggleWithDescriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022301E72BF4C4E70028A287 /* ToggleWithDescriptionView.swift */; }; 0248F9B128DDB09D0041327E /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0248F9B028DDB09D0041327E /* Strings.swift */; }; 0259104429C39C9E004B5A55 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0259104329C39C9E004B5A55 /* SettingsView.swift */; }; 0259104629C39CCF004B5A55 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0259104529C39CCF004B5A55 /* SettingsViewModel.swift */; }; @@ -25,6 +30,8 @@ 025DE1A028DB4D9D0053E0F4 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 025DE19F28DB4D9D0053E0F4 /* Core.framework */; }; 0262149229AE57A1008BD75A /* DeleteAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0262149129AE57A1008BD75A /* DeleteAccountView.swift */; }; 0262149429AE57B1008BD75A /* DeleteAccountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0262149329AE57B1008BD75A /* DeleteAccountViewModel.swift */; }; + 0281D1532BEA9A40006DAD7A /* NewCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0281D1522BEA9A40006DAD7A /* NewCalendarView.swift */; }; + 0281D1552BEBA8D9006DAD7A /* DropDownPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0281D1542BEBA8D9006DAD7A /* DropDownPicker.swift */; }; 0288C4EA2BC6AE2D009158B9 /* ManageAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0288C4E92BC6AE2D009158B9 /* ManageAccountView.swift */; }; 0288C4EC2BC6AE82009158B9 /* ManageAccountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0288C4EB2BC6AE82009158B9 /* ManageAccountViewModel.swift */; }; 029301DA2938948500E99AB8 /* ProfileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029301D92938948500E99AB8 /* ProfileType.swift */; }; @@ -37,6 +44,9 @@ 02D0FD0B2AD6984D0020D752 /* UserProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D0FD0A2AD6984D0020D752 /* UserProfileViewModel.swift */; }; 02F175352A4DAD030019CD70 /* ProfileAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F175342A4DAD030019CD70 /* ProfileAnalytics.swift */; }; 02F3BFE7292539850051930C /* ProfileRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F3BFE6292539850051930C /* ProfileRouter.swift */; }; + 02F81DDF2BF4D83E002D3604 /* CalendarDialogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F81DDE2BF4D83E002D3604 /* CalendarDialogView.swift */; }; + 02F81DE12BF4F009002D3604 /* CoursesToSyncView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F81DE02BF4F009002D3604 /* CoursesToSyncView.swift */; }; + 02F81DE32BF502B9002D3604 /* SyncSelector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F81DE22BF502B9002D3604 /* SyncSelector.swift */; }; 02FE9A802BC707D500B3C206 /* SettingsViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FE9A7F2BC707D400B3C206 /* SettingsViewModelTests.swift */; }; 0796C8C929B7905300444B05 /* ProfileBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0796C8C829B7905300444B05 /* ProfileBottomSheet.swift */; }; 25B36FF48C1307888A3890DA /* Pods_App_Profile.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BEA369C38362C1A91A012F70 /* Pods_App_Profile.framework */; }; @@ -60,6 +70,8 @@ 020306C92932B14D000949EA /* EditProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditProfileViewModel.swift; sourceTree = ""; }; 020CBD8C2BC53E1B003D6B4E /* VideoSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoSettingsView.swift; sourceTree = ""; }; 020F834A28DB4CCD0062FA70 /* Profile.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Profile.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 021C90D42BC986B3004876AF /* DatesAndCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatesAndCalendarView.swift; sourceTree = ""; }; + 021C90D62BC98734004876AF /* DatesAndCalendarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatesAndCalendarViewModel.swift; sourceTree = ""; }; 021D924528DC634300ACC565 /* ProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileView.swift; sourceTree = ""; }; 021D924B28DC884A00ACC565 /* ProfileEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileEndpoint.swift; sourceTree = ""; }; 021D924D28DC88BB00ACC565 /* ProfileRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRepository.swift; sourceTree = ""; }; @@ -67,6 +79,9 @@ 021D925428DC92F800ACC565 /* ProfileInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileInteractor.swift; sourceTree = ""; }; 021D925B28DDADBD00ACC565 /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = ""; }; 021D926028DDADE600ACC565 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 022301E32BF4B7610028A287 /* SyncCalendarOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncCalendarOptionsView.swift; sourceTree = ""; }; + 022301E52BF4B7A20028A287 /* AssignmentStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssignmentStatusView.swift; sourceTree = ""; }; + 022301E72BF4C4E70028A287 /* ToggleWithDescriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleWithDescriptionView.swift; sourceTree = ""; }; 0248F9B028DDB09D0041327E /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; 0259104329C39C9E004B5A55 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 0259104529C39CCF004B5A55 /* SettingsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsViewModel.swift; sourceTree = ""; }; @@ -74,6 +89,8 @@ 025DE19F28DB4D9D0053E0F4 /* Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0262149129AE57A1008BD75A /* DeleteAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAccountView.swift; sourceTree = ""; }; 0262149329AE57B1008BD75A /* DeleteAccountViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAccountViewModel.swift; sourceTree = ""; }; + 0281D1522BEA9A40006DAD7A /* NewCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewCalendarView.swift; sourceTree = ""; }; + 0281D1542BEBA8D9006DAD7A /* DropDownPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropDownPicker.swift; sourceTree = ""; }; 0288C4E92BC6AE2D009158B9 /* ManageAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageAccountView.swift; sourceTree = ""; }; 0288C4EB2BC6AE82009158B9 /* ManageAccountViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageAccountViewModel.swift; sourceTree = ""; }; 029301D92938948500E99AB8 /* ProfileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileType.swift; sourceTree = ""; }; @@ -87,6 +104,9 @@ 02ED50CE29A64BAD008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02F175342A4DAD030019CD70 /* ProfileAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAnalytics.swift; sourceTree = ""; }; 02F3BFE6292539850051930C /* ProfileRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRouter.swift; sourceTree = ""; }; + 02F81DDE2BF4D83E002D3604 /* CalendarDialogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDialogView.swift; sourceTree = ""; }; + 02F81DE02BF4F009002D3604 /* CoursesToSyncView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoursesToSyncView.swift; sourceTree = ""; }; + 02F81DE22BF502B9002D3604 /* SyncSelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncSelector.swift; sourceTree = ""; }; 02FE9A7F2BC707D400B3C206 /* SettingsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = SettingsViewModelTests.swift; path = ProfileTests/Presentation/Settings/SettingsViewModelTests.swift; sourceTree = SOURCE_ROOT; }; 0796C8C829B7905300444B05 /* ProfileBottomSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileBottomSheet.swift; sourceTree = ""; }; 0E5054C44435557666B6D885 /* Pods-App-Profile.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Profile.debugstage.xcconfig"; path = "Target Support Files/Pods-App-Profile/Pods-App-Profile.debugstage.xcconfig"; sourceTree = ""; }; @@ -188,9 +208,22 @@ path = Profile; sourceTree = ""; }; + 021C90D32BC986A4004876AF /* DatesAndCalendar */ = { + isa = PBXGroup; + children = ( + 0281D1512BEA9A2D006DAD7A /* Elements */, + 021C90D42BC986B3004876AF /* DatesAndCalendarView.swift */, + 022301E32BF4B7610028A287 /* SyncCalendarOptionsView.swift */, + 02F81DE02BF4F009002D3604 /* CoursesToSyncView.swift */, + 021C90D62BC98734004876AF /* DatesAndCalendarViewModel.swift */, + ); + path = DatesAndCalendar; + sourceTree = ""; + }; 021D924428DC631800ACC565 /* Presentation */ = { isa = PBXGroup; children = ( + 021C90D32BC986A4004876AF /* DatesAndCalendar */, 0259104229C39C84004B5A55 /* Settings */, 0203DC3D29AE79F80017BD05 /* Profile */, 0203DC3C29AE79EB0017BD05 /* EditProfile */, @@ -265,6 +298,19 @@ path = DeleteAccount; sourceTree = ""; }; + 0281D1512BEA9A2D006DAD7A /* Elements */ = { + isa = PBXGroup; + children = ( + 0281D1522BEA9A40006DAD7A /* NewCalendarView.swift */, + 0281D1542BEBA8D9006DAD7A /* DropDownPicker.swift */, + 022301E52BF4B7A20028A287 /* AssignmentStatusView.swift */, + 022301E72BF4C4E70028A287 /* ToggleWithDescriptionView.swift */, + 02F81DDE2BF4D83E002D3604 /* CalendarDialogView.swift */, + 02F81DE22BF502B9002D3604 /* SyncSelector.swift */, + ); + path = Elements; + sourceTree = ""; + }; 02A4832F29B770B600D33F33 /* Profile */ = { isa = PBXGroup; children = ( @@ -585,13 +631,21 @@ files = ( 021D924E28DC88BB00ACC565 /* ProfileRepository.swift in Sources */, 0288C4EC2BC6AE82009158B9 /* ManageAccountViewModel.swift in Sources */, + 0281D1552BEBA8D9006DAD7A /* DropDownPicker.swift in Sources */, 0796C8C929B7905300444B05 /* ProfileBottomSheet.swift in Sources */, 021D924C28DC884A00ACC565 /* ProfileEndpoint.swift in Sources */, + 02F81DE12BF4F009002D3604 /* CoursesToSyncView.swift in Sources */, + 0281D1532BEA9A40006DAD7A /* NewCalendarView.swift in Sources */, BAD9CA3F2B29BF5C00DE790A /* ProfileSupportInfoView.swift in Sources */, + 022301E82BF4C4E70028A287 /* ToggleWithDescriptionView.swift in Sources */, + 022301E42BF4B7610028A287 /* SyncCalendarOptionsView.swift in Sources */, + 021C90D52BC986B3004876AF /* DatesAndCalendarView.swift in Sources */, 020306C82932B13F000949EA /* EditProfileView.swift in Sources */, 0262149229AE57A1008BD75A /* DeleteAccountView.swift in Sources */, + 02F81DE32BF502B9002D3604 /* SyncSelector.swift in Sources */, 02D0FD092AD698380020D752 /* UserProfileView.swift in Sources */, 0259104629C39CCF004B5A55 /* SettingsViewModel.swift in Sources */, + 021C90D72BC98734004876AF /* DatesAndCalendarViewModel.swift in Sources */, 021D925528DC92F800ACC565 /* ProfileInteractor.swift in Sources */, 029301DA2938948500E99AB8 /* ProfileType.swift in Sources */, 0259104429C39C9E004B5A55 /* SettingsView.swift in Sources */, @@ -601,11 +655,13 @@ 020306CA2932B14D000949EA /* EditProfileViewModel.swift in Sources */, 0259104829C3A5F0004B5A55 /* VideoQualityView.swift in Sources */, 0288C4EA2BC6AE2D009158B9 /* ManageAccountView.swift in Sources */, + 022301E62BF4B7A20028A287 /* AssignmentStatusView.swift in Sources */, 021D924628DC634300ACC565 /* ProfileView.swift in Sources */, 02F3BFE7292539850051930C /* ProfileRouter.swift in Sources */, 02D0FD0B2AD6984D0020D752 /* UserProfileViewModel.swift in Sources */, 020CBD8D2BC53E1B003D6B4E /* VideoSettingsView.swift in Sources */, 02F175352A4DAD030019CD70 /* ProfileAnalytics.swift in Sources */, + 02F81DDF2BF4D83E002D3604 /* CalendarDialogView.swift in Sources */, 0262149429AE57B1008BD75A /* DeleteAccountViewModel.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift b/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift new file mode 100644 index 000000000..5ba8b2944 --- /dev/null +++ b/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift @@ -0,0 +1,134 @@ +// +// CoursesToSyncView.swift +// Profile +// +// Created by  Stepanok Ivan on 15.05.2024. +// + +import SwiftUI +import Theme +import Core + +public struct CoursesToSyncView: View { + + @ObservedObject + private var viewModel: DatesAndCalendarViewModel + + @Environment(\.isHorizontal) private var isHorizontal + + public init(viewModel: DatesAndCalendarViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + GeometryReader { proxy in + ZStack(alignment: .top) { + ThemeAssets.headerBackground.swiftUIImage + .resizable() + .edgesIgnoringSafeArea(.top) + .frame(maxWidth: .infinity, maxHeight: 200) + .accessibilityIdentifier("title_bg_image") + + VStack(alignment: .leading, spacing: 8) { + // MARK: Navigation and Title + NavigationTitle( + title: ProfileLocalization.CoursesToSync.title, + backAction: { + viewModel.router.back() + } + ) + + // MARK: Body + ScrollView { + Group { + Text(ProfileLocalization.CoursesToSync.description) + .font(Theme.Fonts.labelMedium) + .foregroundColor(Theme.Colors.textPrimary) + .padding(.top, 24) + .padding(.horizontal, 24) + .frame( + minWidth: 0, + maxWidth: .infinity, + alignment: .leading + ) + + ToggleWithDescriptionView( + text: ProfileLocalization.CoursesToSync.hideInactiveCourses, + description: ProfileLocalization.CoursesToSync.hideInactiveCoursesDescription, + toggle: $viewModel.hideInactiveCourses + ) + .padding(.horizontal, 24) + .padding(.vertical, 16) + + SyncSelector(sync: $viewModel.synced) + .padding(.horizontal, 24) + + coursesList + } + .padding(.horizontal, isHorizontal ? 48 : 0) + } + .frameLimit(width: proxy.size.width) + .roundedBackground(Theme.Colors.background) + .ignoresSafeArea(.all, edges: .bottom) + } + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + + if viewModel.showError { + ErrorAlertView(errorMessage: $viewModel.errorMessage) + } + } + .ignoresSafeArea(.all, edges: .horizontal) + } + } + + private var coursesList: some View { + VStack(alignment: .leading, spacing: 24) { + ForEach( + Array( + viewModel.coursesForSync.filter({ course in + course.synced == viewModel.synced && (!viewModel.hideInactiveCourses || course.active) + }).enumerated() + ), + id: \.offset + ) { _, course in + HStack { + CheckBoxView( + checked: Binding( + get: { course.synced }, + set: { _ in viewModel.toggleSync(for: course) } + ), + text: course.name, + color: Theme.Colors.textPrimary.opacity(course.active ? 1 : 0.8) + ) + + if !course.active { + Text(ProfileLocalization.CoursesToSync.inactive) + .font(Theme.Fonts.labelSmall) + .foregroundStyle(Theme.Colors.textPrimary.opacity(0.8)) + } + } + .frame( + minWidth: 0, + maxWidth: .infinity, + alignment: .leading + ) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 16) + } +} + +#if DEBUG +struct CoursesToSyncView_Previews: PreviewProvider { + static var previews: some View { + let vm = DatesAndCalendarViewModel( + router: ProfileRouterMock() + ) + return CoursesToSyncView(viewModel: vm) + .previewDisplayName("Courses to Sync") + .loadFonts() + } +} +#endif diff --git a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift new file mode 100644 index 000000000..a1ab29968 --- /dev/null +++ b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift @@ -0,0 +1,195 @@ +// +// DatesAndCalendarView.swift +// Profile +// +// Created by  Stepanok Ivan on 12.04.2024. +// + +import SwiftUI +import Theme +import Core + +public struct DatesAndCalendarView: View { + + @ObservedObject + private var viewModel: DatesAndCalendarViewModel + + @State private var screenDimmed: Bool = false + + @Environment(\.isHorizontal) private var isHorizontal + + public init(viewModel: DatesAndCalendarViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + GeometryReader { proxy in + ZStack(alignment: .top) { + ThemeAssets.headerBackground.swiftUIImage + .resizable() + .edgesIgnoringSafeArea(.top) + .frame(maxWidth: .infinity, maxHeight: 200) + .accessibilityIdentifier("title_bg_image") + + VStack { + // MARK: Navigation and Title + NavigationTitle( + title: ProfileLocalization.DatesAndCalendar.title, + backAction: { + viewModel.router.back() + } + ) + + // MARK: Body + ScrollView { + Group { + calendarSyncCard +// relativeDatesToggle + } + .padding(.horizontal, isHorizontal ? 48 : 0) + } + .frameLimit(width: proxy.size.width) + .roundedBackground(Theme.Colors.background) + .ignoresSafeArea(.all, edges: .bottom) + } + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + + // Error Alert if needed + if viewModel.showError { + ErrorAlertView(errorMessage: $viewModel.errorMessage) + } + if screenDimmed { + Color.black.opacity(0.3) + .ignoresSafeArea() + .onTapGesture { + viewModel.openNewCalendarView = false + screenDimmed = false + } + } + if viewModel.openNewCalendarView { + NewCalendarView( + title: .newCalendar, + viewModel: viewModel, + beginSyncingTapped: { + if viewModel.calendarName == "" { + viewModel.calendarName = viewModel.calendarNameHint + } + viewModel.router.showSyncCalendarOptions() }, + onCloseTapped: { + viewModel.openNewCalendarView = false + screenDimmed = false + } + ) + .transition(.move(edge: .bottom)) + .frame(alignment: .center) + .onAppear { + screenDimmed = true + } + } + + if viewModel.showCalendaAccessDenided { + CalendarDialogView( + type: .calendarAccess, + action: { + viewModel.showCalendaAccessDenided = false + screenDimmed = false + viewModel.openAppSettings() + }, + onCloseTapped: { + viewModel.showCalendaAccessDenided = false + screenDimmed = false + } + ) + .transition(.move(edge: .bottom)) + .frame(alignment: .center) + .onAppear { + screenDimmed = true + } + } + + } + .ignoresSafeArea(.all, edges: .horizontal) + } + } + + // MARK: - Calendar Sync Card + private var calendarSyncCard: some View { + VStack(alignment: .leading) { + Text(ProfileLocalization.CalendarSync.title) + .multilineTextAlignment(.leading) + .padding(.top, 24) + .padding(.horizontal, 24) + .font(Theme.Fonts.bodyMedium) + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .center, spacing: 16) { + CoreAssets.calendarSyncIcon.swiftUIImage + .foregroundStyle(Theme.Colors.textPrimary) + .padding(.bottom, 16) + + Text(ProfileLocalization.CalendarSync.title) + .font(Theme.Fonts.bodyLarge) + .bold() + .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("calendar_sync_title") + + Text(ProfileLocalization.CalendarSync.description) + .font(Theme.Fonts.bodyMedium) + .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("calendar_sync_description") + + StyledButton(ProfileLocalization.CalendarSync.button, action: { + viewModel.requestCalendarPermission() + }, horizontalPadding: true) + .fixedSize() + .accessibilityIdentifier("calendar_sync_button") + } + .frame(minWidth: 0, + maxWidth: .infinity, + alignment: .top) + .multilineTextAlignment(.center) + } + .frame(maxWidth: .infinity, alignment: .topLeading) + .padding(.top, 24) + .cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground, strokeColor: .clear) + } + } + + // MARK: - Options Toggle + private var relativeDatesToggle: some View { + VStack(alignment: .leading) { + Text(ProfileLocalization.Options.title) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + HStack(spacing: 16) { + Toggle("", isOn: $viewModel.useRelativeDates) + .frame(width: 50) + .tint(Theme.Colors.accentColor) + Text(ProfileLocalization.Options.useRelativeDates) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textPrimary) + } + Text(ProfileLocalization.Options.showRelativeDates) + .font(Theme.Fonts.labelMedium) + .foregroundColor(Theme.Colors.textPrimary) + } + .padding(.horizontal, 24) + .frame(minWidth: 0, + maxWidth: .infinity, + alignment: .top) + .accessibilityIdentifier("relative_dates_toggle") + } +} + +#if DEBUG +struct DatesAndCalendarView_Previews: PreviewProvider { + static var previews: some View { + let vm = DatesAndCalendarViewModel( + router: ProfileRouterMock() + ) + DatesAndCalendarView(viewModel: vm) + .loadFonts() + } +} +#endif + diff --git a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift new file mode 100644 index 000000000..a87dff139 --- /dev/null +++ b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift @@ -0,0 +1,131 @@ +// +// DatesAndCalendarViewModel.swift +// Profile +// +// Created by  Stepanok Ivan on 12.04.2024. +// + +import SwiftUI +import Combine +import Core +import EventKit +import Theme + +public struct CourseForSync: Identifiable { + public let id: UUID + public let name: String + public var synced: Bool + public var active: Bool + + public init(id: UUID = UUID(), name: String, synced: Bool, active: Bool) { + self.id = id + self.name = name + self.synced = synced + self.active = active + } +} + +public class DatesAndCalendarViewModel: ObservableObject { + // Output + @Published var useRelativeDates: Bool = false + @Published var showCalendaAccessDenided: Bool = false + @Published var showError: Bool = false + @Published var errorMessage: String? + @Published var openNewCalendarView: Bool = false + + // NewCalendarView + @Published var accountSelection: DropDownPicker.DownPickerOption? = .init( + title: ProfileLocalization.Calendar.Dropdown.icloud + ) + var calendarNameHint: String + @Published var calendarName: String = "" + @Published var colorSelection: DropDownPicker.DownPickerOption? = .init( + title: ProfileLocalization.Calendar.DropdownColor.accent, + color: Theme.Colors.accentColor + ) + + // SyncCalendarOptions + @Published var assignmentStatus: AssignmentStatus = .synced + @Published var courseCalendarSync: Bool = false + @Published var reconnectRequired: Bool = false + @Published var openChangeSyncView: Bool = false + + let accounts: [DropDownPicker.DownPickerOption] = [ + .init(title: ProfileLocalization.Calendar.Dropdown.icloud), + .init(title: ProfileLocalization.Calendar.Dropdown.local) + ] + let colors: [DropDownPicker.DownPickerOption] = [ + .init(title: ProfileLocalization.Calendar.DropdownColor.accent, color: Theme.Colors.accentColor), + .init(title: ProfileLocalization.Calendar.DropdownColor.red, color: .red), + .init(title: ProfileLocalization.Calendar.DropdownColor.orange, color: .orange), + .init(title: ProfileLocalization.Calendar.DropdownColor.yellow, color: .yellow), + .init(title: ProfileLocalization.Calendar.DropdownColor.green, color: .green), + .init(title: ProfileLocalization.Calendar.DropdownColor.blue, color: .blue), + .init(title: ProfileLocalization.Calendar.DropdownColor.purple, color: .purple), + .init(title: ProfileLocalization.Calendar.DropdownColor.brown, color: .brown) + ] + + var router: ProfileRouter + + // CoursesToSyncView + + @Published var coursesForSync = [ + CourseForSync(name: "History of Example Studies", synced: true, active: true), + CourseForSync(name: "Example Language 101", synced: true, active: true), + CourseForSync(name: "Example Course", synced: true, active: true), + CourseForSync(name: "More Example Courses", synced: true, active: true), + CourseForSync(name: "Another Example Course", synced: true, active: true), + CourseForSync(name: "Example Excluded Course", synced: false, active: false), + CourseForSync(name: "Science of Examples", synced: false, active: true), + CourseForSync(name: "Example Learning", synced: false, active: false), + CourseForSync(name: "Science of Examples", synced: false, active: false) + ] + + @Published var synced: Bool = true + @Published var hideInactiveCourses: Bool = false + + func toggleSync(for course: CourseForSync) { + if let index = coursesForSync.firstIndex(where: { $0.id == course.id }) { + if coursesForSync[index].active { + coursesForSync[index].synced.toggle() + } + } + } + + public init(router: ProfileRouter) { + self.router = router + self.calendarNameHint = ProfileLocalization.Calendar.courseDates((Bundle.main.applicationName ?? "")) + } + + // MARK: - Request Calendar Permission + func requestCalendarPermission() { + let eventStore = EKEventStore() + eventStore.requestAccess(to: .event) { [weak self] granted, error in + DispatchQueue.main.async { + if granted { + self?.showNewCalendarSetup() + } else { + self?.showCalendarAccessDenided() + } + } + } + } + + func openAppSettings() { + if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) + } + } + + private func showCalendarAccessDenided() { + withAnimation(.bouncy(duration: 0.3)) { + self.showCalendaAccessDenided = true + } + } + + private func showNewCalendarSetup() { + withAnimation(.bouncy(duration: 0.3)) { + openNewCalendarView = true + } + } +} diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Elements/AssignmentStatusView.swift b/Profile/Profile/Presentation/DatesAndCalendar/Elements/AssignmentStatusView.swift new file mode 100644 index 000000000..b1451a997 --- /dev/null +++ b/Profile/Profile/Presentation/DatesAndCalendar/Elements/AssignmentStatusView.swift @@ -0,0 +1,90 @@ +// +// AssignmentStatusView.swift +// Profile +// +// Created by  Stepanok Ivan on 15.05.2024. +// + +import SwiftUI +import Core +import Theme + +enum AssignmentStatus { + case synced + case failed + case offline + + var statusText: String { + switch self { + case .synced: + ProfileLocalization.AssignmentStatus.synced + case .failed: + ProfileLocalization.AssignmentStatus.failed + case .offline: + ProfileLocalization.AssignmentStatus.offline + } + } + + var image: Image { + switch self { + case .synced: + CoreAssets.synced.swiftUIImage + case .failed: + CoreAssets.syncFailed.swiftUIImage + case .offline: + CoreAssets.syncOffline.swiftUIImage + } + } +} + +struct AssignmentStatusView: View { + + private let title: String + @Binding private var status: AssignmentStatus + private let calendarColor: Color + + init(title: String, status: Binding, calendarColor: Color) { + self.title = title + self._status = status + self.calendarColor = calendarColor + } + + var body: some View { + ZStack { + HStack { + Circle() + .frame(width: 18, height: 18) + .foregroundStyle(calendarColor) + VStack(alignment: .leading, spacing: 4) { + Text(title) + .font(Theme.Fonts.labelLarge) + .foregroundStyle(Theme.Colors.textPrimary) + Text(status.statusText) + .font(Theme.Fonts.labelSmall) + .foregroundStyle(Theme.Colors.textSecondary) + } + .padding(.vertical, 10) + .multilineTextAlignment(.leading) + Spacer() + status.image + } + + .padding(.horizontal, 16) + } + .background( + RoundedRectangle(cornerRadius: 8) + .foregroundStyle(Theme.Colors.textInputUnfocusedBackground) + ) + } +} + +#if DEBUG +#Preview { + AssignmentStatusView( + title: "My Assignments", + status: .constant(.synced), + calendarColor: .blue + ) + .loadFonts() +} +#endif diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Elements/CalendarDialogView.swift b/Profile/Profile/Presentation/DatesAndCalendar/Elements/CalendarDialogView.swift new file mode 100644 index 000000000..c8a6b88db --- /dev/null +++ b/Profile/Profile/Presentation/DatesAndCalendar/Elements/CalendarDialogView.swift @@ -0,0 +1,187 @@ +// +// CalendarDialogView.swift +// Profile +// +// Created by  Stepanok Ivan on 15.05.2024. +// + +import SwiftUI +import Core +import Theme + +struct CalendarDialogView: View { + + enum CalendarDialogType { + case calendarAccess + case disableCalendarSync + + var title: String { + switch self { + case .calendarAccess: + ProfileLocalization.CalendarDialog.calendarAccess + case .disableCalendarSync: + ProfileLocalization.CalendarDialog.disableCalendarSync + } + } + + var description: String { + switch self { + case .calendarAccess: + ProfileLocalization.CalendarDialog.calendarAccessDescription + case .disableCalendarSync: + ProfileLocalization.CalendarDialog.disableCalendarSyncDescription + } + } + } + + @Environment(\.isHorizontal) private var isHorizontal + private var onCloseTapped: (() -> Void) = {} + private var action: (() -> Void) = {} + private let type: CalendarDialogType + private let calendarCircleColor: Color? + private let calendarName: String? + + init( + type: CalendarDialogType, + calendarCircleColor: Color? = nil, + calendarName: String? = nil, + action: @escaping () -> Void, + onCloseTapped: @escaping () -> Void + ) { + self.type = type + self.calendarCircleColor = calendarCircleColor + self.calendarName = calendarName + self.action = action + self.onCloseTapped = onCloseTapped + } + + var body: some View { + ZStack { + Color.clear + .ignoresSafeArea() + if isHorizontal { + ScrollView { + content + .frame(maxWidth: 400) + .padding(24) + .background( + RoundedRectangle(cornerRadius: 8) + .foregroundStyle(Theme.Colors.background) + ) + .padding(24) + } + } else { + content + .frame(maxWidth: 400) + .padding(24) + .background( + RoundedRectangle(cornerRadius: 8) + .foregroundStyle(Theme.Colors.background) + ) + .padding(24) + } + } + } + + private var content: some View { + VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .center) { + CoreAssets.warningFilled.swiftUIImage + .resizable() + .frame(width: 24, height: 24) + Text(type.title) + .font(Theme.Fonts.titleLarge) + .bold() + Spacer() + Button(action: { + onCloseTapped() + }, label: { + Image(systemName: "xmark") + .resizable() + .frame(width: 12, height: 12) + }) + } + + if let calendarName, let calendarCircleColor { + HStack { + Circle() + .frame(width: 18, height: 18) + .foregroundStyle(calendarCircleColor) + Text(calendarName) + .strikethrough() + .font(Theme.Fonts.labelLarge) + .foregroundStyle(Theme.Colors.textPrimary) + .multilineTextAlignment(.leading) + Spacer() + } + .padding(16) + .background( + RoundedRectangle(cornerRadius: 8) + .foregroundStyle(Theme.Colors.textInputUnfocusedBackground) + ) + } + + Text(type.description) + .font(Theme.Fonts.bodySmall) + .foregroundColor(Theme.Colors.textPrimary) + .multilineTextAlignment(.leading) + + VStack(spacing: 16) { + switch type { + case .calendarAccess: + StyledButton( + ProfileLocalization.CalendarDialog.grantCalendarAccess, + action: { + action() + }, + iconImage: CoreAssets.calendarAccess.swiftUIImage, + iconPosition: .right + ) + StyledButton( + ProfileLocalization.CalendarDialog.cancel, + action: { + onCloseTapped() + }, + color: Theme.Colors.background, + textColor: Theme.Colors.accentColor, + borderColor: Theme.Colors.accentColor + ) + case .disableCalendarSync: + StyledButton( + ProfileLocalization.CalendarDialog.disableSyncing, + action: { + action() + }, + color: Theme.Colors.background, + textColor: Theme.Colors.accentColor, + borderColor: Theme.Colors.accentColor + ) + + StyledButton(ProfileLocalization.CalendarDialog.cancel) { + onCloseTapped() + } + } + } + .padding(.top, 16) + .frame( + minWidth: 0, + maxWidth: .infinity, + alignment: .center + ) + } + .frame(maxWidth: 360) + } +} + +#if DEBUG +#Preview { + CalendarDialogView( + type: .calendarAccess, + calendarCircleColor: .blue, + calendarName: "My Assignments", + action: {}, + onCloseTapped: {} + ) + .loadFonts() +} +#endif diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Elements/DropDownPicker.swift b/Profile/Profile/Presentation/DatesAndCalendar/Elements/DropDownPicker.swift new file mode 100644 index 000000000..bef40545b --- /dev/null +++ b/Profile/Profile/Presentation/DatesAndCalendar/Elements/DropDownPicker.swift @@ -0,0 +1,184 @@ +// +// DropDownPicker.swift +// Profile +// +// Created by  Stepanok Ivan on 08.05.2024. +// + +import SwiftUI +import Core +import Theme + +enum DropDownPickerState { + case top + case bottom +} + +struct DropDownPicker: View { + + struct DownPickerOption: Hashable { + let title: String + let color: Color? + + init(title: String, color: Color? = nil) { + self.title = title + self.color = color + } + + func hash(into hasher: inout Hasher) { + hasher.combine(title) + } + + static func == (lhs: DownPickerOption, rhs: DownPickerOption) -> Bool { + lhs.title == rhs.title + } + } + + @Binding var selection: DownPickerOption? + var state: DropDownPickerState = .bottom + var options: [DownPickerOption] + + @State var showDropdown = false + + @State private var index = 1000.0 + @State var zindex = 1000.0 + + init(selection: Binding, state: DropDownPickerState, options: [DownPickerOption]) { + self._selection = selection + self.state = state + self.options = options + } + + var body: some View { + GeometryReader { + let size = $0.size + VStack(spacing: 0) { + if state == .top && showDropdown { + optionsView() + } + HStack { + if let color = selection?.color { + Circle() + .frame(width: 18, height: 18) + .foregroundStyle(color) + } + Text( + selection == nil + ? ProfileLocalization.DropDownPicker.select + : selection!.title + ) + .foregroundStyle(Theme.Colors.textPrimary) + .font(Theme.Fonts.bodyMedium) + Spacer(minLength: 0) + Image(systemName: state == .top ? "chevron.up" : "chevron.down") + .foregroundColor(Theme.Colors.textPrimary) + .rotationEffect(.degrees((showDropdown ? -180 : 0))) + } + .padding(.horizontal, 15) + .contentShape(.rect) + .onTapGesture { + index += 1 + zindex = index + withAnimation(.bouncy(duration: 0.2)) { + showDropdown.toggle() + } + } + .zIndex(10) + .frame(height: 48) + .background(Theme.Colors.background) + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.textInputStroke, lineWidth: 1) + .padding(1) + } + + if state == .bottom && showDropdown { + optionsView() + .overlay { + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.textInputStroke, lineWidth: 1) + .padding(1) + } + .padding(.top, 4) + } + } + .clipped() + .background(Theme.Colors.background) + .cornerRadius(8) + .frame(height: size.height, alignment: state == .top ? .bottom : .top) + .onTapBackground(enabled: showDropdown, { showDropdown = false }) + } + .frame(height: 48) + .zIndex(zindex) + } + + func optionsView() -> some View { + + func menuHeight() -> Double { + if options.count < 3 { + return Double(options.count * 56) + } else { + return 200.0 + } + } + + return ScrollView { + VStack(spacing: 0) { + ForEach(options, id: \.self) { option in + ZStack { + HStack { + if let color = option.color { + Circle() + .frame(width: 18, height: 18) + .foregroundStyle(color) + } + Text(option.title) + .font(Theme.Fonts.bodyMedium) + .foregroundStyle(Theme.Colors.textPrimary) + Spacer() +// Image(systemName: "checkmark") +// .opacity(selection == option ? 1 : 0) + } + VStack { + Spacer() + if option != options.last { + Theme.Colors.textInputStroke + .frame(height: 1) + .padding(.top, 8) + .frame(alignment: .bottom) + } + } + } + .foregroundStyle(selection == option ? Color.primary : Color.gray) + .animation(.easeIn(duration: 0.2), value: selection) + .frame(height: 56) + .contentShape(.rect) + .padding(.horizontal, 15) + .onTapGesture { + withAnimation(.easeIn(duration: 0.2)) { + selection = option + showDropdown.toggle() + } + } + } + } + .padding(.top, 4) + }.frame(height: menuHeight()) + .transition(.move(edge: state == .top ? .bottom : .top)) + .zIndex(1) + } +} + +#Preview { + DropDownPicker( + selection: .constant(.init(title: "Selected")), + state: .bottom, + options: [ + .init(title: "One"), + .init( + title: "Two" + ) + ] + ) + .loadFonts() +} diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift b/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift new file mode 100644 index 000000000..64d9342a1 --- /dev/null +++ b/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift @@ -0,0 +1,154 @@ +// +// NewCalendarView.swift +// Profile +// +// Created by  Stepanok Ivan on 07.05.2024. +// + +import SwiftUI +import Core +import Theme + +struct NewCalendarView: View { + + enum Title { + case newCalendar + case changeSyncOptions + + var text: String { + switch self { + case .newCalendar: + ProfileLocalization.Calendar.newCalendar + case .changeSyncOptions: + ProfileLocalization.Calendar.changeSyncOptions + } + } + } + + @ObservedObject + private var viewModel: DatesAndCalendarViewModel + @Environment(\.isHorizontal) private var isHorizontal + private var beginSyncingTapped: (() -> Void) = {} + private var onCloseTapped: (() -> Void) = {} + + private let title: Title + + init( + title: Title, + viewModel: DatesAndCalendarViewModel, + beginSyncingTapped: @escaping () -> Void, + onCloseTapped: @escaping () -> Void + ) { + self.title = title + self.viewModel = viewModel + self.beginSyncingTapped = beginSyncingTapped + self.onCloseTapped = onCloseTapped + } + + var body: some View { + ZStack { + Color.clear + .ignoresSafeArea() + if isHorizontal { + ScrollView { + content + + } + } else { + content + } + } + } + + private var content: some View { + VStack(alignment: .leading) { + HStack(alignment: .center) { + Text(title.text) + .font(Theme.Fonts.titleLarge) + .bold() + Spacer() + Button(action: { + onCloseTapped() + }, label: { + Image(systemName: "xmark") + .resizable() + .frame(width: 12, height: 12) + }) + } + .padding(.bottom, 20) + Text(ProfileLocalization.Calendar.account) + .font(Theme.Fonts.bodySmall).bold() + DropDownPicker(selection: $viewModel.accountSelection, state: .bottom, options: viewModel.accounts) + + Text(ProfileLocalization.Calendar.calendarName) + .font(Theme.Fonts.bodySmall).bold() + .padding(.top, 16) + TextField(viewModel.calendarNameHint, text: $viewModel.calendarName) + .font(Theme.Fonts.bodyLarge) + .padding() + .background(Theme.Colors.background) + .cornerRadius(8) + .frame(height: 48) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.textInputStroke, lineWidth: 1) + .padding(1) + ) + + Text(ProfileLocalization.Calendar.color) + .font(Theme.Fonts.bodySmall).bold() + .padding(.top, 16) + DropDownPicker(selection: $viewModel.colorSelection, state: .bottom, options: viewModel.colors) + + Text(ProfileLocalization.Calendar.upcomingAssignments) + .font(Theme.Fonts.bodySmall) + .foregroundColor(Theme.Colors.textPrimary) + .padding(.vertical, 16) + .multilineTextAlignment(.center) + .frame( + minWidth: 0, + maxWidth: .infinity, + alignment: .center + ) + + VStack(spacing: 16) { + StyledButton(ProfileLocalization.Calendar.cancel, + action: { + onCloseTapped() + }, + color: Theme.Colors.background, + textColor: Theme.Colors.accentColor, + borderColor: Theme.Colors.accentColor + ) + + StyledButton(ProfileLocalization.Calendar.beginSyncing) { + beginSyncingTapped() + } + } + .frame( + minWidth: 0, + maxWidth: .infinity, + alignment: .center + ) + } + .frame(maxWidth: 360) + .padding(24) + .background( + RoundedRectangle(cornerRadius: 8) + .foregroundStyle(Theme.Colors.background) + ) + .padding(24) + } +} + +#if DEBUG +#Preview { + NewCalendarView( + title: .newCalendar, + viewModel: DatesAndCalendarViewModel(router: ProfileRouterMock()), + beginSyncingTapped: {}, + onCloseTapped: {} + ) + .loadFonts() +} +#endif diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Elements/SyncSelector.swift b/Profile/Profile/Presentation/DatesAndCalendar/Elements/SyncSelector.swift new file mode 100644 index 000000000..42ee8a552 --- /dev/null +++ b/Profile/Profile/Presentation/DatesAndCalendar/Elements/SyncSelector.swift @@ -0,0 +1,60 @@ +// +// SyncSelector.swift +// Profile +// +// Created by  Stepanok Ivan on 15.05.2024. +// + +import SwiftUI +import Core +import Theme + +struct SyncSelector: View { + @Binding var sync: Bool + + var body: some View { + HStack(spacing: 2) { + Button(action: { + sync = true + }) { + Text(ProfileLocalization.SyncSelector.synced) + .font(Theme.Fonts.bodyMedium) + .frame(maxWidth: .infinity) + .padding() + .background(sync ? Theme.Colors.accentColor : Theme.Colors.background) + .foregroundColor(sync ? Theme.Colors.white : Theme.Colors.accentColor) + .clipShape(RoundedCorners(tl: 8, bl: 8)) + } + .overlay( + RoundedCorners(tl: 8, bl: 8) + .stroke(Theme.Colors.accentColor, lineWidth: 1) + .padding(.vertical, 0.5) + ) + Button(action: { + sync = false + }) { + Text(ProfileLocalization.SyncSelector.notSynced) + .font(Theme.Fonts.bodyMedium) + .frame(maxWidth: .infinity) + .padding() + .background(sync ? Theme.Colors.background : Theme.Colors.accentColor) + .foregroundColor(sync ? Theme.Colors.accentColor : Theme.Colors.white) + .clipShape(RoundedCorners(tr: 8, br: 8)) + } + .overlay( + RoundedCorners(tr: 8, br: 8) + .stroke(Theme.Colors.accentColor, lineWidth: 1) + .padding(.vertical, 0.5) + ) + } + + .frame(height: 42) + } +} + +#if DEBUG +#Preview { + SyncSelector(sync: .constant(true)) + .padding(8) +} +#endif diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Elements/ToggleWithDescriptionView.swift b/Profile/Profile/Presentation/DatesAndCalendar/Elements/ToggleWithDescriptionView.swift new file mode 100644 index 000000000..fed25a7df --- /dev/null +++ b/Profile/Profile/Presentation/DatesAndCalendar/Elements/ToggleWithDescriptionView.swift @@ -0,0 +1,98 @@ +// +// ToggleWithDescriptionView.swift +// Profile +// +// Created by  Stepanok Ivan on 15.05.2024. +// + +import SwiftUI +import Theme +import Core + +struct ToggleWithDescriptionView: View { + + let text: String + let description: String + @Binding var toggle: Bool + @Binding var showAlertIcon: Bool + + init( + text: String, + description: String, + toggle: Binding, + showAlertIcon: Binding = .constant(false) + ) { + self.text = text + self.description = description + self._toggle = toggle + self._showAlertIcon = showAlertIcon + } + + var body: some View { + VStack(alignment: .leading, spacing: 18) { +// HStack(spacing: 12) { + Toggle(isOn: $toggle, label: { + HStack { + Text(text) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textPrimary) + if showAlertIcon { + CoreAssets.warningFilled.swiftUIImage + .resizable() + .frame(width: 24, height: 24) + } + } + }) + .tint(Theme.Colors.accentColor) +// CustomToggle(isOn: $toggle) +// .padding(.leading, 10) +// Text(text) +// .font(Theme.Fonts.bodyLarge) +// .foregroundColor(Theme.Colors.textPrimary) +// if showAlertIcon { +// CoreAssets.warningFilled.swiftUIImage +// .resizable() +// .frame(width: 24, height: 24) +// } +// } + Text(description) + .font(Theme.Fonts.labelMedium) + .foregroundColor(Theme.Colors.textPrimary) + } + .frame(minWidth: 0, + maxWidth: .infinity, + alignment: .leading) + .accessibilityIdentifier("\(text)_toggle") + } +} + +#Preview { + ToggleWithDescriptionView( + text: "Use relative dates", + description: "Show relative dates like “Tomorrow” and “Yesterday”", + toggle: .constant(true), + showAlertIcon: .constant(true) + ) + .loadFonts() +} + +struct CustomToggle: View { + @Binding var isOn: Bool + + var body: some View { + Button(action: { + isOn.toggle() + }) { + RoundedRectangle(cornerRadius: 10) + .fill(isOn ? Theme.Colors.accentColor : Color.gray) + .frame(width: 37, height: 20) + .overlay( + Circle() + .fill(Color.white) + .frame(width: 16, height: 16) + .offset(x: isOn ? 8 : -8) + .animation(.easeInOut(duration: 0.2), value: isOn) + ) + } + } +} diff --git a/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift b/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift new file mode 100644 index 000000000..ecb0f213d --- /dev/null +++ b/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift @@ -0,0 +1,231 @@ +// +// SyncCalendarOptionsView.swift +// Profile +// +// Created by  Stepanok Ivan on 15.05.2024. +// + +import SwiftUI +import Theme +import Core + +public struct SyncCalendarOptionsView: View { + + @ObservedObject + private var viewModel: DatesAndCalendarViewModel + + @State private var screenDimmed: Bool = false + + @Environment(\.isHorizontal) private var isHorizontal + + public init(viewModel: DatesAndCalendarViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + GeometryReader { proxy in + ZStack(alignment: .top) { + ThemeAssets.headerBackground.swiftUIImage + .resizable() + .edgesIgnoringSafeArea(.top) + .frame(maxWidth: .infinity, maxHeight: 200) + .accessibilityIdentifier("title_bg_image") + + VStack(spacing: 8) { + // MARK: Navigation and Title + NavigationTitle( + title: ProfileLocalization.DatesAndCalendar.title, + backAction: { + viewModel.router.back() + } + ) + + // MARK: Body + ScrollView { + Group { + if let colorSelectionColor = viewModel.colorSelection?.color { + optionTitle(ProfileLocalization.CalendarSync.title) + .padding(.top, 24) + AssignmentStatusView( + title: viewModel.calendarName, + status: $viewModel.assignmentStatus, + calendarColor: colorSelectionColor + ) + .padding(.horizontal, 24) + } + ToggleWithDescriptionView( + text: ProfileLocalization.CourseCalendarSync.title, + description: viewModel.reconnectRequired + ? ProfileLocalization.CourseCalendarSync.Description.reconnectRequired + : ProfileLocalization.CourseCalendarSync.Description.syncing, + toggle: $viewModel.courseCalendarSync, + showAlertIcon: $viewModel.reconnectRequired + ) + .padding(.vertical, 24) + .padding(.horizontal, 24) + + StyledButton( + viewModel.reconnectRequired + ? ProfileLocalization.CourseCalendarSync.Button.reconnect + : ProfileLocalization.CourseCalendarSync.Button.changeSyncOptions, + action: { + screenDimmed = true + withAnimation(.bouncy(duration: 0.3)) { + if viewModel.reconnectRequired { + viewModel.showCalendaAccessDenided = true + } else { + viewModel.openChangeSyncView = true + } + } + }, + color: viewModel.reconnectRequired + ? Theme.Colors.accentColor + : Theme.Colors.background, + textColor: viewModel.reconnectRequired + ? Theme.Colors.styledButtonText + : Theme.Colors.accentColor, + borderColor: viewModel.reconnectRequired + ? .clear + : Theme.Colors.accentColor + ) + .padding(.horizontal, 24) + if !viewModel.reconnectRequired { + optionTitle(ProfileLocalization.CoursesToSync.title) + .padding(.top, 24) + coursesToSync + .padding(.bottom, 24) + } + relativeDatesToggle + } + .padding(.horizontal, isHorizontal ? 48 : 0) + .frameLimit(width: proxy.size.width) + } + .roundedBackground(Theme.Colors.background) + .ignoresSafeArea(.all, edges: .bottom) + } + .navigationBarHidden(true) + .navigationBarBackButtonHidden(true) + + // Error Alert if needed + if viewModel.showError { + ErrorAlertView(errorMessage: $viewModel.errorMessage) + } + if screenDimmed { + Color.black.opacity(0.3) + .ignoresSafeArea() + .onTapGesture { + viewModel.openChangeSyncView = false + viewModel.showCalendaAccessDenided = false + screenDimmed = false + } + } + if viewModel.openChangeSyncView { + NewCalendarView( + title: .changeSyncOptions, + viewModel: viewModel, + beginSyncingTapped: {}, + onCloseTapped: { + viewModel.openChangeSyncView = false + screenDimmed = false + } + ) + .transition(.move(edge: .bottom)) + .frame(alignment: .center) + } else if viewModel.showCalendaAccessDenided { + CalendarDialogView( + type: .calendarAccess, + action: { + viewModel.showCalendaAccessDenided = false + screenDimmed = false + viewModel.openAppSettings() + }, + onCloseTapped: { + viewModel.showCalendaAccessDenided = false + screenDimmed = false + } + ) + .transition(.move(edge: .bottom)) + .frame(alignment: .center) + } + + } + .ignoresSafeArea(.all, edges: .horizontal) + } + } + + // MARK: - Options Title + + private func optionTitle(_ text: String) -> some View { + Text(text) + .multilineTextAlignment(.leading) + .font(Theme.Fonts.labelLarge) + .foregroundStyle(Theme.Colors.textPrimary) + .padding(.horizontal, 24) + .frame( + minWidth: 0, + maxWidth: .infinity, + alignment: .leading + ) + } + + // MARK: - Courses to Sync + @ViewBuilder + private var coursesToSync: some View { + + VStack(alignment: .leading, spacing: 27) { + Button(action: { + // viewModel.trackProfileVideoSettingsClicked() + viewModel.router.showCoursesToSync() + }, + label: { + HStack { + Text( + String( + format: ProfileLocalization.CoursesToSync.syncingCourses( + viewModel.coursesForSync.count + ) + ) + ) + .font(Theme.Fonts.titleMedium) + Spacer() + Image(systemName: "chevron.right") + } + }) + .accessibilityIdentifier("courses_to_sync_cell") + + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(ProfileLocalization.settingsVideo) + .cardStyle( + bgColor: Theme.Colors.textInputUnfocusedBackground, + strokeColor: .clear + ) + } + + @ViewBuilder + private var relativeDatesToggle: some View { + Divider() + .padding(.horizontal, 24) + + optionTitle(ProfileLocalization.Options.title) + .padding(.vertical, 16) + ToggleWithDescriptionView( + text: ProfileLocalization.Options.useRelativeDates, + description: ProfileLocalization.Options.showRelativeDates, + toggle: $viewModel.reconnectRequired + ) + .padding(.horizontal, 24) + } +} + +#if DEBUG +struct SyncCalendarOptionsView_Previews: PreviewProvider { + static var previews: some View { + let vm = DatesAndCalendarViewModel( + router: ProfileRouterMock() + ) + SyncCalendarOptionsView(viewModel: vm) + .loadFonts() + } +} +#endif diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index b643845bd..5b2a04c77 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -141,7 +141,7 @@ public struct ProfileView: View { } } } - + // MARK: - Profile Info @ViewBuilder private var profileInfo: some View { diff --git a/Profile/Profile/Presentation/ProfileRouter.swift b/Profile/Profile/Presentation/ProfileRouter.swift index 624a05e21..dca085668 100644 --- a/Profile/Profile/Presentation/ProfileRouter.swift +++ b/Profile/Profile/Presentation/ProfileRouter.swift @@ -24,6 +24,12 @@ public protocol ProfileRouter: BaseRouter { func showManageAccount() + func showDatesAndCalendar() + + func showSyncCalendarOptions() + + func showCoursesToSync() + func showVideoQualityView(viewModel: SettingsViewModel) func showVideoDownloadQualityView( @@ -52,6 +58,12 @@ public class ProfileRouterMock: BaseRouterMock, ProfileRouter { public func showVideoSettings() {} + public func showDatesAndCalendar() {} + + public func showSyncCalendarOptions() {} + + public func showCoursesToSync() {} + public func showManageAccount() {} public func showVideoQualityView(viewModel: SettingsViewModel) {} diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index 047e238ad..590afa784 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -67,6 +67,7 @@ public struct SettingsView: View { } else { manageAccount settings + datesAndCalendar ProfileSupportInfoView(viewModel: viewModel) logOutButton } @@ -108,6 +109,34 @@ public struct SettingsView: View { .ignoresSafeArea(.all, edges: .horizontal) } + // MARK: - Dates & Calendar + + @ViewBuilder + private var datesAndCalendar: some View { + + VStack(alignment: .leading, spacing: 27) { + Button(action: { +// viewModel.trackProfileVideoSettingsClicked() + viewModel.router.showDatesAndCalendar() + }, label: { + HStack { + Text("Dates & Calendar") // TODO: add ProfileLocalization... + .font(Theme.Fonts.titleMedium) + Spacer() + Image(systemName: "chevron.right") + } + }) + .accessibilityIdentifier("dates_and_calendar_cell") + + } + .accessibilityElement(children: .ignore) + .accessibilityLabel(ProfileLocalization.settingsVideo) + .cardStyle( + bgColor: Theme.Colors.textInputUnfocusedBackground, + strokeColor: .clear + ) + } + // MARK: - Manage Account @ViewBuilder private var manageAccount: some View { diff --git a/Profile/Profile/SwiftGen/Strings.swift b/Profile/Profile/SwiftGen/Strings.swift index 5c26b5557..0cf331373 100644 --- a/Profile/Profile/SwiftGen/Strings.swift +++ b/Profile/Profile/SwiftGen/Strings.swift @@ -53,6 +53,120 @@ public enum ProfileLocalization { public static let title = ProfileLocalization.tr("Localizable", "TITLE", fallback: "Profile") /// Year of birth: public static let yearOfBirth = ProfileLocalization.tr("Localizable", "YEAR_OF_BIRTH", fallback: "Year of birth:") + public enum AssignmentStatus { + /// Sync Failed + public static let failed = ProfileLocalization.tr("Localizable", "ASSIGNMENT_STATUS.FAILED", fallback: "Sync Failed") + /// Offline + public static let offline = ProfileLocalization.tr("Localizable", "ASSIGNMENT_STATUS.OFFLINE", fallback: "Offline") + /// Synced + public static let synced = ProfileLocalization.tr("Localizable", "ASSIGNMENT_STATUS.SYNCED", fallback: "Synced") + } + public enum Calendar { + /// Account + public static let account = ProfileLocalization.tr("Localizable", "CALENDAR.ACCOUNT", fallback: "Account") + /// Begin Syncing + public static let beginSyncing = ProfileLocalization.tr("Localizable", "CALENDAR.BEGIN_SYNCING", fallback: "Begin Syncing") + /// Calendar Name + public static let calendarName = ProfileLocalization.tr("Localizable", "CALENDAR.CALENDAR_NAME", fallback: "Calendar Name") + /// Cancel + public static let cancel = ProfileLocalization.tr("Localizable", "CALENDAR.CANCEL", fallback: "Cancel") + /// Change Sync Options + public static let changeSyncOptions = ProfileLocalization.tr("Localizable", "CALENDAR.CHANGE_SYNC_OPTIONS", fallback: "Change Sync Options") + /// Color + public static let color = ProfileLocalization.tr("Localizable", "CALENDAR.COLOR", fallback: "Color") + /// %@ Course Dates + public static func courseDates(_ p1: Any) -> String { + return ProfileLocalization.tr("Localizable", "CALENDAR.COURSE_DATES", String(describing: p1), fallback: "%@ Course Dates") + } + /// New Calendar + public static let newCalendar = ProfileLocalization.tr("Localizable", "CALENDAR.NEW_CALENDAR", fallback: "New Calendar") + /// Upcoming assignments for active courses will appear on this calendar + public static let upcomingAssignments = ProfileLocalization.tr("Localizable", "CALENDAR.UPCOMING_ASSIGNMENTS", fallback: "Upcoming assignments for active courses will appear on this calendar") + public enum Dropdown { + /// iCloud + public static let icloud = ProfileLocalization.tr("Localizable", "CALENDAR.DROPDOWN.ICLOUD", fallback: "iCloud") + /// Local + public static let local = ProfileLocalization.tr("Localizable", "CALENDAR.DROPDOWN.LOCAL", fallback: "Local") + } + public enum DropdownColor { + /// Accent + public static let accent = ProfileLocalization.tr("Localizable", "CALENDAR.DROPDOWN_COLOR.ACCENT", fallback: "Accent") + /// Blue + public static let blue = ProfileLocalization.tr("Localizable", "CALENDAR.DROPDOWN_COLOR.BLUE", fallback: "Blue") + /// Brown + public static let brown = ProfileLocalization.tr("Localizable", "CALENDAR.DROPDOWN_COLOR.BROWN", fallback: "Brown") + /// Green + public static let green = ProfileLocalization.tr("Localizable", "CALENDAR.DROPDOWN_COLOR.GREEN", fallback: "Green") + /// Orange + public static let orange = ProfileLocalization.tr("Localizable", "CALENDAR.DROPDOWN_COLOR.ORANGE", fallback: "Orange") + /// Purple + public static let purple = ProfileLocalization.tr("Localizable", "CALENDAR.DROPDOWN_COLOR.PURPLE", fallback: "Purple") + /// Red + public static let red = ProfileLocalization.tr("Localizable", "CALENDAR.DROPDOWN_COLOR.RED", fallback: "Red") + /// Yellow + public static let yellow = ProfileLocalization.tr("Localizable", "CALENDAR.DROPDOWN_COLOR.YELLOW", fallback: "Yellow") + } + } + public enum CalendarDialog { + /// Calendar Access + public static let calendarAccess = ProfileLocalization.tr("Localizable", "CALENDAR_DIALOG.CALENDAR_ACCESS", fallback: "Calendar Access") + /// To show upcoming assignments and course milestones on your calendar, we need permission to access your calendar. + public static let calendarAccessDescription = ProfileLocalization.tr("Localizable", "CALENDAR_DIALOG.CALENDAR_ACCESS_DESCRIPTION", fallback: "To show upcoming assignments and course milestones on your calendar, we need permission to access your calendar.") + /// Cancel + public static let cancel = ProfileLocalization.tr("Localizable", "CALENDAR_DIALOG.CANCEL", fallback: "Cancel") + /// Change Sync Options + public static let disableCalendarSync = ProfileLocalization.tr("Localizable", "CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC", fallback: "Change Sync Options") + /// Disabling calendar sync will delete the calendar “My Assignments.” You can turn calendar sync back on at any time. + public static let disableCalendarSyncDescription = ProfileLocalization.tr("Localizable", "CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC_DESCRIPTION", fallback: "Disabling calendar sync will delete the calendar “My Assignments.” You can turn calendar sync back on at any time.") + /// Disable Syncing + public static let disableSyncing = ProfileLocalization.tr("Localizable", "CALENDAR_DIALOG.DISABLE_SYNCING", fallback: "Disable Syncing") + /// Grant Calendar Access + public static let grantCalendarAccess = ProfileLocalization.tr("Localizable", "CALENDAR_DIALOG.GRANT_CALENDAR_ACCESS", fallback: "Grant Calendar Access") + } + public enum CalendarSync { + /// Set Up Calendar Sync + public static let button = ProfileLocalization.tr("Localizable", "CALENDAR_SYNC.BUTTON", fallback: "Set Up Calendar Sync") + /// Set up calendar sync to show your upcoming assignments and course milestones on your calendar. New assignments and shifted course dates will sync automatically + public static let description = ProfileLocalization.tr("Localizable", "CALENDAR_SYNC.DESCRIPTION", fallback: "Set up calendar sync to show your upcoming assignments and course milestones on your calendar. New assignments and shifted course dates will sync automatically") + /// Calendar Sync + public static let title = ProfileLocalization.tr("Localizable", "CALENDAR_SYNC.TITLE", fallback: "Calendar Sync") + } + public enum CoursesToSync { + /// Disabling sync for a course will remove all events connected to the course from your synced calendar. + public static let description = ProfileLocalization.tr("Localizable", "COURSES_TO_SYNC.DESCRIPTION", fallback: "Disabling sync for a course will remove all events connected to the course from your synced calendar.") + /// Hide Inactive Courses + public static let hideInactiveCourses = ProfileLocalization.tr("Localizable", "COURSES_TO_SYNC.HIDE_INACTIVE_COURSES", fallback: "Hide Inactive Courses") + /// Automatically remove events from courses you haven’t viewed in the last month + public static let hideInactiveCoursesDescription = ProfileLocalization.tr("Localizable", "COURSES_TO_SYNC.HIDE_INACTIVE_COURSES_DESCRIPTION", fallback: "Automatically remove events from courses you haven’t viewed in the last month") + /// Inactive + public static let inactive = ProfileLocalization.tr("Localizable", "COURSES_TO_SYNC.INACTIVE", fallback: "Inactive") + /// Syncing %d Courses + public static func syncingCourses(_ p1: Int) -> String { + return ProfileLocalization.tr("Localizable", "COURSES_TO_SYNC.SYNCING_COURSES", p1, fallback: "Syncing %d Courses") + } + /// Courses to Sync + public static let title = ProfileLocalization.tr("Localizable", "COURSES_TO_SYNC.TITLE", fallback: "Courses to Sync") + } + public enum CourseCalendarSync { + /// Course Calendar Sync + public static let title = ProfileLocalization.tr("Localizable", "COURSE_CALENDAR_SYNC.TITLE", fallback: "Course Calendar Sync") + public enum Button { + /// Change Sync Options + public static let changeSyncOptions = ProfileLocalization.tr("Localizable", "COURSE_CALENDAR_SYNC.BUTTON.CHANGE_SYNC_OPTIONS", fallback: "Change Sync Options") + /// Reconnect Calendar + public static let reconnect = ProfileLocalization.tr("Localizable", "COURSE_CALENDAR_SYNC.BUTTON.RECONNECT", fallback: "Reconnect Calendar") + } + public enum Description { + /// Please reconnect your calendar to resume syncing + public static let reconnectRequired = ProfileLocalization.tr("Localizable", "COURSE_CALENDAR_SYNC.DESCRIPTION.RECONNECT_REQUIRED", fallback: "Please reconnect your calendar to resume syncing") + /// Currently syncing events to your calendar + public static let syncing = ProfileLocalization.tr("Localizable", "COURSE_CALENDAR_SYNC.DESCRIPTION.SYNCING", fallback: "Currently syncing events to your calendar") + } + } + public enum DatesAndCalendar { + /// Dates & Calendar + public static let title = ProfileLocalization.tr("Localizable", "DATES_AND_CALENDAR.TITLE", fallback: "Dates & Calendar") + } public enum DeleteAccount { /// Are you sure you want to public static let areYouSure = ProfileLocalization.tr("Localizable", "DELETE_ACCOUNT.ARE_YOU_SURE", fallback: "Are you sure you want to ") @@ -79,6 +193,10 @@ public enum ProfileLocalization { /// Warning! public static let title = ProfileLocalization.tr("Localizable", "DELETE_ALERT.TITLE", fallback: "Warning!") } + public enum DropDownPicker { + /// Select + public static let select = ProfileLocalization.tr("Localizable", "DROP_DOWN_PICKER.SELECT", fallback: "Select") + } public enum Edit { /// Delete Account public static let deleteAccount = ProfileLocalization.tr("Localizable", "EDIT.DELETE_ACCOUNT", fallback: "Delete Account") @@ -117,6 +235,14 @@ public enum ProfileLocalization { /// Comfirm log out public static let title = ProfileLocalization.tr("Localizable", "LOGOUT_ALERT.TITLE", fallback: "Comfirm log out") } + public enum Options { + /// Show relative dates like “Tomorrow” and “Yesterday” + public static let showRelativeDates = ProfileLocalization.tr("Localizable", "OPTIONS.SHOW_RELATIVE_DATES", fallback: "Show relative dates like “Tomorrow” and “Yesterday”") + /// Options + public static let title = ProfileLocalization.tr("Localizable", "OPTIONS.TITLE", fallback: "Options") + /// Use relative dates + public static let useRelativeDates = ProfileLocalization.tr("Localizable", "OPTIONS.USE_RELATIVE_DATES", fallback: "Use relative dates") + } public enum Settings { /// Lower data usage public static let quality360Description = ProfileLocalization.tr("Localizable", "SETTINGS.QUALITY_360_DESCRIPTION", fallback: "Lower data usage") @@ -151,6 +277,12 @@ public enum ProfileLocalization { /// Wi-fi only download public static let wifiTitle = ProfileLocalization.tr("Localizable", "SETTINGS.WIFI_TITLE", fallback: "Wi-fi only download") } + public enum SyncSelector { + /// Not Synced + public static let notSynced = ProfileLocalization.tr("Localizable", "SYNC_SELECTOR.NOT_SYNCED", fallback: "Not Synced") + /// Synced + public static let synced = ProfileLocalization.tr("Localizable", "SYNC_SELECTOR.SYNCED", fallback: "Synced") + } public enum UnsavedDataAlert { /// 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.") diff --git a/Profile/Profile/en.lproj/Localizable.strings b/Profile/Profile/en.lproj/Localizable.strings index be69b026d..9b6872159 100644 --- a/Profile/Profile/en.lproj/Localizable.strings +++ b/Profile/Profile/en.lproj/Localizable.strings @@ -82,3 +82,68 @@ "SETTINGS.TAP_TO_INSTALL" = "Tap to install required app update"; "ERROR.CANNOT_SEND_EMAIL" = "Cannot send email. It seems your email client is not set up."; + +"CALENDAR.NEW_CALENDAR" = "New Calendar"; +"CALENDAR.CHANGE_SYNC_OPTIONS" = "Change Sync Options"; +"CALENDAR.ACCOUNT" = "Account"; +"CALENDAR.CALENDAR_NAME" = "Calendar Name"; +"CALENDAR.COLOR" = "Color"; +"CALENDAR.UPCOMING_ASSIGNMENTS" = "Upcoming assignments for active courses will appear on this calendar"; +"CALENDAR.CANCEL" = "Cancel"; +"CALENDAR.BEGIN_SYNCING" = "Begin Syncing"; + +"ASSIGNMENT_STATUS.SYNCED" = "Synced"; +"ASSIGNMENT_STATUS.FAILED" = "Sync Failed"; +"ASSIGNMENT_STATUS.OFFLINE" = "Offline"; + +"CALENDAR_DIALOG.CALENDAR_ACCESS" = "Calendar Access"; +"CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC" = "Change Sync Options"; +"CALENDAR_DIALOG.CALENDAR_ACCESS_DESCRIPTION" = "To show upcoming assignments and course milestones on your calendar, we need permission to access your calendar."; +"CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC_DESCRIPTION" = "Disabling calendar sync will delete the calendar “My Assignments.” You can turn calendar sync back on at any time."; +"CALENDAR_DIALOG.GRANT_CALENDAR_ACCESS" = "Grant Calendar Access"; +"CALENDAR_DIALOG.DISABLE_SYNCING" = "Disable Syncing"; +"CALENDAR_DIALOG.CANCEL" = "Cancel"; + +"DATES_AND_CALENDAR.TITLE" = "Dates & Calendar"; +"CALENDAR_SYNC.TITLE" = "Calendar Sync"; +"CALENDAR_SYNC.DESCRIPTION" = "Set up calendar sync to show your upcoming assignments and course milestones on your calendar. New assignments and shifted course dates will sync automatically"; +"CALENDAR_SYNC.BUTTON" = "Set Up Calendar Sync"; +"OPTIONS.TITLE" = "Options"; +"OPTIONS.USE_RELATIVE_DATES" = "Use relative dates"; +"OPTIONS.SHOW_RELATIVE_DATES" = "Show relative dates like “Tomorrow” and “Yesterday”"; + +"DATES_AND_CALENDAR.TITLE" = "Dates & Calendar"; +"COURSE_CALENDAR_SYNC.TITLE" = "Course Calendar Sync"; +"COURSE_CALENDAR_SYNC.DESCRIPTION.RECONNECT_REQUIRED" = "Please reconnect your calendar to resume syncing"; +"COURSE_CALENDAR_SYNC.DESCRIPTION.SYNCING" = "Currently syncing events to your calendar"; +"COURSE_CALENDAR_SYNC.BUTTON.RECONNECT" = "Reconnect Calendar"; +"COURSE_CALENDAR_SYNC.BUTTON.CHANGE_SYNC_OPTIONS" = "Change Sync Options"; +"COURSES_TO_SYNC.SYNCING_COURSES" = "Syncing %d Courses"; +"OPTIONS.TITLE" = "Options"; +"OPTIONS.USE_RELATIVE_DATES" = "Use relative dates"; +"OPTIONS.SHOW_RELATIVE_DATES" = "Show relative dates like “Tomorrow” and “Yesterday”"; + +"COURSES_TO_SYNC.TITLE" = "Courses to Sync"; +"COURSES_TO_SYNC.DESCRIPTION" = "Disabling sync for a course will remove all events connected to the course from your synced calendar."; +"COURSES_TO_SYNC.HIDE_INACTIVE_COURSES" = "Hide Inactive Courses"; +"COURSES_TO_SYNC.HIDE_INACTIVE_COURSES_DESCRIPTION" = "Automatically remove events from courses you haven’t viewed in the last month"; +"COURSES_TO_SYNC.INACTIVE" = "Inactive"; + +"CALENDAR.DROPDOWN.ICLOUD" = "iCloud"; +"CALENDAR.DROPDOWN.LOCAL" = "Local"; + +"CALENDAR.DROPDOWN_COLOR.ACCENT" = "Accent"; +"CALENDAR.DROPDOWN_COLOR.RED" = "Red"; +"CALENDAR.DROPDOWN_COLOR.ORANGE" = "Orange"; +"CALENDAR.DROPDOWN_COLOR.YELLOW" = "Yellow"; +"CALENDAR.DROPDOWN_COLOR.GREEN" = "Green"; +"CALENDAR.DROPDOWN_COLOR.BLUE" = "Blue"; +"CALENDAR.DROPDOWN_COLOR.PURPLE" = "Purple"; +"CALENDAR.DROPDOWN_COLOR.BROWN" = "Brown"; + +"CALENDAR.COURSE_DATES" = "%@ Course Dates"; + +"DROP_DOWN_PICKER.SELECT" = "Select"; + +"SYNC_SELECTOR.SYNCED" = "Synced"; +"SYNC_SELECTOR.NOT_SYNCED" = "Not Synced"; diff --git a/Profile/Profile/uk.lproj/Localizable.strings b/Profile/Profile/uk.lproj/Localizable.strings index dbc3c5379..a8590f765 100644 --- a/Profile/Profile/uk.lproj/Localizable.strings +++ b/Profile/Profile/uk.lproj/Localizable.strings @@ -80,3 +80,66 @@ "SETTINGS.TAP_TO_INSTALL" = "Клацніть, щоб встановити обов'язкове оновлення програми"; "ERROR.CANNOT_SEND_EMAIL" = "Cannot send email. It seems your email client is not set up."; + +"CALENDAR.NEW_CALENDAR" = "Новий календар"; +"CALENDAR.CHANGE_SYNC_OPTIONS" = "Змінити параметри синхронізації"; +"CALENDAR.ACCOUNT" = "Обліковий запис"; +"CALENDAR.CALENDAR_NAME" = "Назва календаря"; +"CALENDAR.COLOR" = "Колір"; +"CALENDAR.UPCOMING_ASSIGNMENTS" = "Майбутні завдання для активних курсів з’являться у цьому календарі"; +"CALENDAR.CANCEL" = "Скасувати"; +"CALENDAR.BEGIN_SYNCING" = "Почати синхронізацію"; + +"ASSIGNMENT_STATUS.SYNCED" = "Синхронізовано"; +"ASSIGNMENT_STATUS.FAILED" = "Синхронізація не вдалася"; +"ASSIGNMENT_STATUS.OFFLINE" = "Офлайн"; + +"CALENDAR_DIALOG.CALENDAR_ACCESS" = "Доступ до календаря"; +"CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC" = "Змінити параметри синхронізації"; +"CALENDAR_DIALOG.CALENDAR_ACCESS_DESCRIPTION" = "Щоб показати майбутні завдання та віхи курсу у вашому календарі, нам потрібен дозвіл на доступ до вашого календаря."; +"CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC_DESCRIPTION" = "Вимкнення синхронізації календаря видалить календар “Мої завдання”. Ви можете знову увімкнути синхронізацію календаря в будь-який час."; +"CALENDAR_DIALOG.GRANT_CALENDAR_ACCESS" = "Надати доступ до календаря"; +"CALENDAR_DIALOG.DISABLE_SYNCING" = "Вимкнути синхронізацію"; +"CALENDAR_DIALOG.CANCEL" = "Скасувати"; + +"DATES_AND_CALENDAR.TITLE" = "Дати та календар"; +"CALENDAR_SYNC.TITLE" = "Синхронізація календаря"; +"CALENDAR_SYNC.DESCRIPTION" = "Налаштуйте синхронізацію календаря, щоб показувати майбутні завдання та віхи курсу у вашому календарі. Нові завдання та змінені дати курсів будуть синхронізуватися автоматично"; +"CALENDAR_SYNC.BUTTON" = "Налаштувати синхронізацію календаря"; +"OPTIONS.TITLE" = "Опції"; +"OPTIONS.USE_RELATIVE_DATES" = "Використовувати відносні дати"; +"OPTIONS.SHOW_RELATIVE_DATES" = "Показувати відносні дати, такі як “Завтра” і “Вчора”"; + +"DATES_AND_CALENDAR.TITLE" = "Дати та календарі"; +"CALENDAR_SYNC.TITLE" = "Синхронізація календаря"; +"COURSE_CALENDAR_SYNC.TITLE" = "Синхронізація календаря курсу"; +"COURSE_CALENDAR_SYNC.DESCRIPTION.RECONNECT_REQUIRED" = "Будь ласка, повторно підключіть свій календар для відновлення синхронізації"; +"COURSE_CALENDAR_SYNC.DESCRIPTION.SYNCING" = "В даний час події синхронізуються з вашим календарем"; +"COURSE_CALENDAR_SYNC.BUTTON.RECONNECT" = "Повторно підключити календар"; +"COURSE_CALENDAR_SYNC.BUTTON.CHANGE_SYNC_OPTIONS" = "Змінити параметри синхронізації"; +"COURSES_TO_SYNC.TITLE" = "Синхронізація %d курсів"; +"OPTIONS.TITLE" = "Опції"; +"OPTIONS.USE_RELATIVE_DATES" = "Використовувати відносні дати"; +"OPTIONS.SHOW_RELATIVE_DATES" = "Показувати відносні дати, такі як “Завтра” і “Вчора”"; + +"COURSES_TO_SYNC.TITLE" = "Курси для синхронізації"; +"COURSES_TO_SYNC.DESCRIPTION" = "Вимкнення синхронізації для курсу видалить усі події, пов’язані з курсом, із вашого синхронізованого календаря."; +"COURSES_TO_SYNC.HIDE_INACTIVE_COURSES" = "Приховати неактивні курси"; +"COURSES_TO_SYNC.HIDE_INACTIVE_COURSES_DESCRIPTION" = "Автоматично видаляйте події з курсів, які ви не переглядали протягом останнього місяця"; +"COURSES_TO_SYNC.INACTIVE" = "Неактивний"; + +"CALENDAR.DROPDOWN.ICLOUD" = "iCloud"; +"CALENDAR.DROPDOWN.LOCAL" = "Локальний"; + +"CALENDAR.DROPDOWN_COLOR.ACCENT" = "Акцентний"; +"CALENDAR.DROPDOWN_COLOR.RED" = "Червоний"; +"CALENDAR.DROPDOWN_COLOR.ORANGE" = "Помаранчевий"; +"CALENDAR.DROPDOWN_COLOR.YELLOW" = "Жовтий"; +"CALENDAR.DROPDOWN_COLOR.GREEN" = "Зелений"; +"CALENDAR.DROPDOWN_COLOR.BLUE" = "Синій"; +"CALENDAR.DROPDOWN_COLOR.PURPLE" = "Фіолетовий"; +"CALENDAR.DROPDOWN_COLOR.BROWN" = "Коричневий"; + +"CALENDAR.COURSE_DATES" = "%@ Дати курсу"; + +"DROP_DOWN_PICKER.SELECT" = "Оберіть"; diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 8f927c355..1a3cd757b 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -3057,6 +3057,24 @@ open class ProfileRouterMock: ProfileRouter, Mock { perform?() } + open func showDatesAndCalendar() { + addInvocation(.m_showDatesAndCalendar) + let perform = methodPerformValue(.m_showDatesAndCalendar) as? () -> Void + perform?() + } + + open func showSyncCalendarOptions() { + addInvocation(.m_showSyncCalendarOptions) + let perform = methodPerformValue(.m_showSyncCalendarOptions) as? () -> Void + perform?() + } + + open func showCoursesToSync() { + addInvocation(.m_showCoursesToSync) + let perform = methodPerformValue(.m_showCoursesToSync) as? () -> Void + perform?() + } + open func showVideoQualityView(viewModel: SettingsViewModel) { addInvocation(.m_showVideoQualityView__viewModel_viewModel(Parameter.value(`viewModel`))) let perform = methodPerformValue(.m_showVideoQualityView__viewModel_viewModel(Parameter.value(`viewModel`))) as? (SettingsViewModel) -> Void @@ -3177,6 +3195,9 @@ open class ProfileRouterMock: ProfileRouter, Mock { case m_showSettings case m_showVideoSettings case m_showManageAccount + case m_showDatesAndCalendar + case m_showSyncCalendarOptions + case m_showCoursesToSync case m_showVideoQualityView__viewModel_viewModel(Parameter) case m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics(Parameter, Parameter<((DownloadQuality) -> Void)?>, Parameter) case m_showDeleteProfileView @@ -3212,6 +3233,12 @@ open class ProfileRouterMock: ProfileRouter, Mock { case (.m_showManageAccount, .m_showManageAccount): return .match + case (.m_showDatesAndCalendar, .m_showDatesAndCalendar): return .match + + case (.m_showSyncCalendarOptions, .m_showSyncCalendarOptions): return .match + + case (.m_showCoursesToSync, .m_showCoursesToSync): return .match + case (.m_showVideoQualityView__viewModel_viewModel(let lhsViewmodel), .m_showVideoQualityView__viewModel_viewModel(let rhsViewmodel)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsViewmodel, rhs: rhsViewmodel, with: matcher), lhsViewmodel, rhsViewmodel, "viewModel")) @@ -3324,6 +3351,9 @@ open class ProfileRouterMock: ProfileRouter, Mock { case .m_showSettings: return 0 case .m_showVideoSettings: return 0 case .m_showManageAccount: return 0 + case .m_showDatesAndCalendar: return 0 + case .m_showSyncCalendarOptions: return 0 + case .m_showCoursesToSync: return 0 case let .m_showVideoQualityView__viewModel_viewModel(p0): return p0.intValue case let .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue case .m_showDeleteProfileView: return 0 @@ -3351,6 +3381,9 @@ open class ProfileRouterMock: ProfileRouter, Mock { case .m_showSettings: return ".showSettings()" case .m_showVideoSettings: return ".showVideoSettings()" case .m_showManageAccount: return ".showManageAccount()" + case .m_showDatesAndCalendar: return ".showDatesAndCalendar()" + case .m_showSyncCalendarOptions: return ".showSyncCalendarOptions()" + case .m_showCoursesToSync: return ".showCoursesToSync()" case .m_showVideoQualityView__viewModel_viewModel: return ".showVideoQualityView(viewModel:)" case .m_showVideoDownloadQualityView__downloadQuality_downloadQualitydidSelect_didSelectanalytics_analytics: return ".showVideoDownloadQualityView(downloadQuality:didSelect:analytics:)" case .m_showDeleteProfileView: return ".showDeleteProfileView()" @@ -3392,6 +3425,9 @@ open class ProfileRouterMock: ProfileRouter, Mock { public static func showSettings() -> Verify { return Verify(method: .m_showSettings)} public static func showVideoSettings() -> Verify { return Verify(method: .m_showVideoSettings)} public static func showManageAccount() -> Verify { return Verify(method: .m_showManageAccount)} + public static func showDatesAndCalendar() -> Verify { return Verify(method: .m_showDatesAndCalendar)} + public static func showSyncCalendarOptions() -> Verify { return Verify(method: .m_showSyncCalendarOptions)} + public static func showCoursesToSync() -> Verify { return Verify(method: .m_showCoursesToSync)} 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)?>, 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)} @@ -3429,6 +3465,15 @@ open class ProfileRouterMock: ProfileRouter, Mock { public static func showManageAccount(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_showManageAccount, performs: perform) } + public static func showDatesAndCalendar(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showDatesAndCalendar, performs: perform) + } + public static func showSyncCalendarOptions(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showSyncCalendarOptions, performs: perform) + } + public static func showCoursesToSync(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showCoursesToSync, performs: perform) + } public static func showVideoQualityView(viewModel: Parameter, perform: @escaping (SettingsViewModel) -> Void) -> Perform { return Perform(method: .m_showVideoQualityView__viewModel_viewModel(`viewModel`), performs: perform) } From d8a7b466e13a8cf95e457aafba98ca5ee5e25fa8 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Sat, 1 Jun 2024 16:37:22 +0300 Subject: [PATCH 10/55] feat: [FC-0047] Improved Dashboard Level Navigation (#434) * feat: replace Discover page with new Learn page * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: change DropDown menu arrow direction * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback --- Core/Core.xcodeproj/project.pbxproj | 20 +- .../learn.imageset/Contents.json | 15 + .../learn.imageset/learn filled.svg | 10 + .../chevron_right.imageset/Contents.json | 15 + .../chevron_right.imageset/chevron_right.svg | 15 + .../learn_empty.imageset/Contents.json | 12 + .../learn_empty.imageset/learn_big.svg | 14 + .../resumeCourse.imageset/Contents.json | 21 ++ .../resumeCourse.imageset/resumeCourse.svg | 3 + .../settings.imageset/Contents.json | 2 +- .../icon-manage_accounts.svg | 5 + .../settings.imageset/settingsIcon.svg | 4 - .../viewAll.imageset/Contents.json | 12 + .../viewAll.imageset/viewAll.svg | 14 + Core/Core/Configuration/Config/Config.swift | 1 + .../Config/DashboardConfig.swift | 34 ++ .../Config/DiscoveryConfig.swift | 5 + Core/Core/Data/Model/Data_Discovery.swift | 6 +- ...Dashboard.swift => Data_Enrollments.swift} | 22 +- .../Data/Model/Data_PrimaryEnrollment.swift | 269 +++++++++++++ Core/Core/Domain/Model/CourseItem.swift | 14 +- .../Core/Domain/Model/PrimaryEnrollment.swift | 93 +++++ Core/Core/Extensions/DateExtension.swift | 14 + Core/Core/Extensions/Notification.swift | 1 + Core/Core/Extensions/ViewExtension.swift | 2 +- Core/Core/SwiftGen/Assets.swift | 5 + Core/Core/SwiftGen/Strings.swift | 10 + Core/Core/View/Base/CourseCellView.swift | 7 +- .../ScrollSlidingTabBar.swift | 3 + Core/Core/en.lproj/Localizable.strings | 5 + Core/Core/uk.lproj/Localizable.strings | 1 + .../CourseCoreModel.xcdatamodel/contents | 6 +- Course/Course/Domain/CourseInteractor.swift | 1 + .../Container/CourseContainerView.swift | 7 +- .../Container/CourseContainerViewModel.swift | 68 +++- .../Outline/CourseOutlineView.swift | 24 +- .../CourseContainerViewModelTests.swift | 13 + Dashboard/Dashboard.xcodeproj/project.pbxproj | 64 +++- .../Dashboard/Data/DashboardRepository.swift | 150 ++++++-- .../Data/Network/DashboardEndpoint.swift | 29 +- .../DashboardCoreModel.xcdatamodel/contents | 44 ++- .../DashboardPersistenceProtocol.swift | 6 +- .../Domain/DashboardInteractor.swift | 27 +- .../Presentation/AllCoursesView.swift | 211 +++++++++++ .../Presentation/AllCoursesViewModel.swift | 105 ++++++ .../Presentation/DashboardRouter.swift | 23 +- .../Elements/CategoryFilterView.swift | 91 +++++ .../Elements/CourseCardView.swift | 125 ++++++ .../Presentation/Elements/DropDownMenu.swift | 91 +++++ .../Presentation/Elements/NoCoursesView.swift | 89 +++++ .../Elements/PrimaryCardView.swift | 276 ++++++++++++++ .../Elements/ProgressLineView.swift | 48 +++ ...oardView.swift => ListDashboardView.swift} | 26 +- ...del.swift => ListDashboardViewModel.swift} | 10 +- .../PrimaryCourseDashboardView.swift | 357 ++++++++++++++++++ .../PrimaryCourseDashboardViewModel.swift | 102 +++++ Dashboard/Dashboard/SwiftGen/Strings.swift | 66 ++++ .../Dashboard/en.lproj/Localizable.strings | 32 ++ .../Dashboard/uk.lproj/Localizable.strings | 31 ++ .../DashboardMock.generated.swift | 197 ++++++++-- .../DashboardViewModelTests.swift | 52 +-- .../Discovery/Data/DiscoveryRepository.swift | 18 +- .../DiscoveryCoreModel.xcdatamodel/contents | 4 +- .../Presentation/DiscoveryRouter.swift | 12 +- .../NativeDiscovery/CourseDetailsView.swift | 6 +- .../NativeDiscovery/SearchView.swift | 2 +- .../DiscoveryWebviewViewModel.swift | 6 +- .../WebPrograms/ProgramWebviewViewModel.swift | 6 +- .../DiscoveryViewModelTests.swift | 36 +- .../Presentation/SearchViewModelTests.swift | 12 +- OpenEdX.xcodeproj/project.pbxproj | 48 +-- OpenEdX/DI/ScreenAssembly.swift | 27 +- OpenEdX/Data/CoursePersistence.swift | 11 +- OpenEdX/Data/DashboardPersistence.swift | 192 +++++++++- OpenEdX/Data/DiscoveryPersistence.swift | 10 +- .../DeepLinkRouter/DeepLinkRouter.swift | 6 +- OpenEdX/Managers/PipManager.swift | 9 +- OpenEdX/Router.swift | 31 +- OpenEdX/View/MainScreenView.swift | 117 +++--- .../Colors/AccentColor.colorset/Contents.json | 6 +- .../AccentXColor.colorset/Contents.json | 6 +- .../CardViewStroke.colorset/Contents.json | 6 +- .../Contents.json | 6 +- .../CourseCardShadow.colorset/Contents.json | 38 ++ Theme/Theme/SwiftGen/ThemeAssets.swift | 1 + Theme/Theme/Theme.swift | 1 + 86 files changed, 3302 insertions(+), 340 deletions(-) create mode 100644 Core/Core/Assets.xcassets/NavigationBar/learn.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/NavigationBar/learn.imageset/learn filled.svg create mode 100644 Core/Core/Assets.xcassets/chevron_right.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/chevron_right.imageset/chevron_right.svg create mode 100644 Core/Core/Assets.xcassets/learn_empty.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/learn_empty.imageset/learn_big.svg create mode 100644 Core/Core/Assets.xcassets/resumeCourse.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/resumeCourse.imageset/resumeCourse.svg create mode 100644 Core/Core/Assets.xcassets/settings.imageset/icon-manage_accounts.svg delete mode 100644 Core/Core/Assets.xcassets/settings.imageset/settingsIcon.svg create mode 100644 Core/Core/Assets.xcassets/viewAll.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/viewAll.imageset/viewAll.svg create mode 100644 Core/Core/Configuration/Config/DashboardConfig.swift rename Core/Core/Data/Model/{Data_Dashboard.swift => Data_Enrollments.swift} (93%) create mode 100644 Core/Core/Data/Model/Data_PrimaryEnrollment.swift create mode 100644 Core/Core/Domain/Model/PrimaryEnrollment.swift create mode 100644 Dashboard/Dashboard/Presentation/AllCoursesView.swift create mode 100644 Dashboard/Dashboard/Presentation/AllCoursesViewModel.swift create mode 100644 Dashboard/Dashboard/Presentation/Elements/CategoryFilterView.swift create mode 100644 Dashboard/Dashboard/Presentation/Elements/CourseCardView.swift create mode 100644 Dashboard/Dashboard/Presentation/Elements/DropDownMenu.swift create mode 100644 Dashboard/Dashboard/Presentation/Elements/NoCoursesView.swift create mode 100644 Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift create mode 100644 Dashboard/Dashboard/Presentation/Elements/ProgressLineView.swift rename Dashboard/Dashboard/Presentation/{DashboardView.swift => ListDashboardView.swift} (90%) rename Dashboard/Dashboard/Presentation/{DashboardViewModel.swift => ListDashboardViewModel.swift} (90%) create mode 100644 Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift create mode 100644 Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift create mode 100644 Theme/Theme/Assets.xcassets/Colors/CourseCardShadow.colorset/Contents.json diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 25f7353d2..3d8d938c7 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -65,6 +65,8 @@ 028CE96929858ECC00B6B1C3 /* FlexibleKeyboardInputView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028CE96829858ECC00B6B1C3 /* FlexibleKeyboardInputView.swift */; }; 028F9F37293A44C700DE65D0 /* Data_ResetPassword.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028F9F36293A44C700DE65D0 /* Data_ResetPassword.swift */; }; 028F9F39293A452B00DE65D0 /* ResetPassword.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028F9F38293A452B00DE65D0 /* ResetPassword.swift */; }; + 02935B732BCECAD000B22F66 /* Data_PrimaryEnrollment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02935B722BCECAD000B22F66 /* Data_PrimaryEnrollment.swift */; }; + 02935B752BCEE6D600B22F66 /* PrimaryEnrollment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02935B742BCEE6D600B22F66 /* PrimaryEnrollment.swift */; }; 0295C885299B99DD00ABE571 /* RefreshableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0295C884299B99DD00ABE571 /* RefreshableScrollView.swift */; }; 029EE3ED2BF6650500F64F33 /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029EE3EC2BF6650500F64F33 /* Bundle.swift */; }; 02A463112AEA966C00331037 /* AppReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A463102AEA966C00331037 /* AppReviewView.swift */; }; @@ -77,7 +79,8 @@ 02B2B594295C5C7A00914876 /* Thread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B2B593295C5C7A00914876 /* Thread.swift */; }; 02B3E3B32930198600A50475 /* AVPlayerViewControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B3E3B22930198600A50475 /* AVPlayerViewControllerExtension.swift */; }; 02B3F16E2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B3F16D2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift */; }; - 02C917F029CDA99E00DBB8BD /* Data_Dashboard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C917EF29CDA99E00DBB8BD /* Data_Dashboard.swift */; }; + 02C917F029CDA99E00DBB8BD /* Data_Enrollments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C917EF29CDA99E00DBB8BD /* Data_Enrollments.swift */; }; + 02CA59842BD7DDBE00D517AA /* DashboardConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CA59832BD7DDBE00D517AA /* DashboardConfig.swift */; }; 02CF46C829546AA200A698EE /* NoCachedDataError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CF46C729546AA200A698EE /* NoCachedDataError.swift */; }; 02D400612B0678190029D168 /* SKStoreReviewControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D400602B0678190029D168 /* SKStoreReviewControllerExtension.swift */; }; 02D800CC29348F460099CF16 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D800CB29348F460099CF16 /* ImagePicker.swift */; }; @@ -250,6 +253,8 @@ 028CE96829858ECC00B6B1C3 /* FlexibleKeyboardInputView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlexibleKeyboardInputView.swift; sourceTree = ""; }; 028F9F36293A44C700DE65D0 /* Data_ResetPassword.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_ResetPassword.swift; sourceTree = ""; }; 028F9F38293A452B00DE65D0 /* ResetPassword.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetPassword.swift; sourceTree = ""; }; + 02935B722BCECAD000B22F66 /* Data_PrimaryEnrollment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_PrimaryEnrollment.swift; sourceTree = ""; }; + 02935B742BCEE6D600B22F66 /* PrimaryEnrollment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryEnrollment.swift; sourceTree = ""; }; 0295C884299B99DD00ABE571 /* RefreshableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableScrollView.swift; sourceTree = ""; }; 029EE3EC2BF6650500F64F33 /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = ""; }; 02A463102AEA966C00331037 /* AppReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReviewView.swift; sourceTree = ""; }; @@ -262,7 +267,8 @@ 02B2B593295C5C7A00914876 /* Thread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thread.swift; sourceTree = ""; }; 02B3E3B22930198600A50475 /* AVPlayerViewControllerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayerViewControllerExtension.swift; sourceTree = ""; }; 02B3F16D2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableScrollViewCompat.swift; sourceTree = ""; }; - 02C917EF29CDA99E00DBB8BD /* Data_Dashboard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_Dashboard.swift; sourceTree = ""; }; + 02C917EF29CDA99E00DBB8BD /* Data_Enrollments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_Enrollments.swift; sourceTree = ""; }; + 02CA59832BD7DDBE00D517AA /* DashboardConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardConfig.swift; sourceTree = ""; }; 02CF46C729546AA200A698EE /* NoCachedDataError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoCachedDataError.swift; sourceTree = ""; }; 02D400602B0678190029D168 /* SKStoreReviewControllerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SKStoreReviewControllerExtension.swift; sourceTree = ""; }; 02D800CB29348F460099CF16 /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; }; @@ -592,7 +598,8 @@ 0727877628D23847002E9142 /* DataLayer.swift */, 0727878428D31657002E9142 /* Data_User.swift */, 0283347C28D4D3DE00C828FC /* Data_Discovery.swift */, - 02C917EF29CDA99E00DBB8BD /* Data_Dashboard.swift */, + 02C917EF29CDA99E00DBB8BD /* Data_Enrollments.swift */, + 02935B722BCECAD000B22F66 /* Data_PrimaryEnrollment.swift */, 021D924728DC860C00ACC565 /* Data_UserProfile.swift */, 0259104929C4A5B6004B5A55 /* UserSettings.swift */, 070019A428F6F17900D5FC78 /* Data_Media.swift */, @@ -618,6 +625,7 @@ children = ( 0727878828D31734002E9142 /* User.swift */, 0284DBFD28D48C5300830893 /* CourseItem.swift */, + 02935B742BCEE6D600B22F66 /* PrimaryEnrollment.swift */, 021D924F28DC89D100ACC565 /* UserProfile.swift */, 070019AB28F6FD0100D5FC78 /* CourseDetailBlock.swift */, 0248C92229C075EF00DC8402 /* CourseBlockModel.swift */, @@ -848,6 +856,7 @@ BAFB99832B0E282E007D09F9 /* MicrosoftConfig.swift */, BAFB998F2B14B377007D09F9 /* GoogleConfig.swift */, BAFB99912B14E23D007D09F9 /* AppleSignInConfig.swift */, + 02CA59832BD7DDBE00D517AA /* DashboardConfig.swift */, A53A32342B233DEC005FE38A /* ThemeConfig.swift */, E0D586192B2FF74C009B4BA7 /* DiscoveryConfig.swift */, ); @@ -1086,6 +1095,7 @@ 0255D5582936283A004DBC1A /* UploadBodyEncoding.swift in Sources */, 06619EAF2B973B25001FAADE /* AccessibilityInjection.swift in Sources */, BAFB99822B0E2354007D09F9 /* FacebookConfig.swift in Sources */, + 02935B732BCECAD000B22F66 /* Data_PrimaryEnrollment.swift in Sources */, 027BD3B32909475900392132 /* Publishers+KeyboardState.swift in Sources */, 06DEA4A32BBD66A700110D20 /* BackNavigationButton.swift in Sources */, 0727877D28D25212002E9142 /* ProgressBar.swift in Sources */, @@ -1129,7 +1139,7 @@ 02AFCC1A2AEFDC18000360F0 /* ThirdPartyMailer.swift in Sources */, 0770DE5F28D0B22C006D8A5D /* Strings.swift in Sources */, BA981BCE2B8F5C49005707C2 /* Sequence+Extensions.swift in Sources */, - 02C917F029CDA99E00DBB8BD /* Data_Dashboard.swift in Sources */, + 02C917F029CDA99E00DBB8BD /* Data_Enrollments.swift in Sources */, 024FCD0028EF1CD300232339 /* WebBrowser.swift in Sources */, 027BD3B52909475900392132 /* KeyboardStateObserver.swift in Sources */, 0283347D28D4D3DE00C828FC /* Data_Discovery.swift in Sources */, @@ -1192,9 +1202,11 @@ 02F98A7F28F81EE900DE94C0 /* Container+App.swift in Sources */, 02B3F16E2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift in Sources */, 0649879A2B4D69FF0071642A /* WebViewHTML.swift in Sources */, + 02CA59842BD7DDBE00D517AA /* DashboardConfig.swift in Sources */, 0727877B28D24A1D002E9142 /* HeadersRedirectHandler.swift in Sources */, 0236961B28F9A28B00EEF206 /* AuthInteractor.swift in Sources */, 0770DE3028D09793006D8A5D /* EndPointType.swift in Sources */, + 02935B752BCEE6D600B22F66 /* PrimaryEnrollment.swift in Sources */, 020C31C9290AC3F700D6DEA2 /* PickerFields.swift in Sources */, 02F6EF3B28D9B8EC00835477 /* CourseCellView.swift in Sources */, 023A1138291432FD00D0D354 /* FieldConfiguration.swift in Sources */, diff --git a/Core/Core/Assets.xcassets/NavigationBar/learn.imageset/Contents.json b/Core/Core/Assets.xcassets/NavigationBar/learn.imageset/Contents.json new file mode 100644 index 000000000..718131171 --- /dev/null +++ b/Core/Core/Assets.xcassets/NavigationBar/learn.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "learn filled.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Core/Core/Assets.xcassets/NavigationBar/learn.imageset/learn filled.svg b/Core/Core/Assets.xcassets/NavigationBar/learn.imageset/learn filled.svg new file mode 100644 index 000000000..c961205bc --- /dev/null +++ b/Core/Core/Assets.xcassets/NavigationBar/learn.imageset/learn filled.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/Core/Core/Assets.xcassets/chevron_right.imageset/Contents.json b/Core/Core/Assets.xcassets/chevron_right.imageset/Contents.json new file mode 100644 index 000000000..a21ea6e5d --- /dev/null +++ b/Core/Core/Assets.xcassets/chevron_right.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "chevron_right.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Core/Core/Assets.xcassets/chevron_right.imageset/chevron_right.svg b/Core/Core/Assets.xcassets/chevron_right.imageset/chevron_right.svg new file mode 100644 index 000000000..e951c4282 --- /dev/null +++ b/Core/Core/Assets.xcassets/chevron_right.imageset/chevron_right.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/Core/Core/Assets.xcassets/learn_empty.imageset/Contents.json b/Core/Core/Assets.xcassets/learn_empty.imageset/Contents.json new file mode 100644 index 000000000..f823d5953 --- /dev/null +++ b/Core/Core/Assets.xcassets/learn_empty.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "learn_big.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/learn_empty.imageset/learn_big.svg b/Core/Core/Assets.xcassets/learn_empty.imageset/learn_big.svg new file mode 100644 index 000000000..a1874861e --- /dev/null +++ b/Core/Core/Assets.xcassets/learn_empty.imageset/learn_big.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Core/Core/Assets.xcassets/resumeCourse.imageset/Contents.json b/Core/Core/Assets.xcassets/resumeCourse.imageset/Contents.json new file mode 100644 index 000000000..1ab6cc7ba --- /dev/null +++ b/Core/Core/Assets.xcassets/resumeCourse.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "resumeCourse.svg", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/resumeCourse.imageset/resumeCourse.svg b/Core/Core/Assets.xcassets/resumeCourse.imageset/resumeCourse.svg new file mode 100644 index 000000000..0af03cb0c --- /dev/null +++ b/Core/Core/Assets.xcassets/resumeCourse.imageset/resumeCourse.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/settings.imageset/Contents.json b/Core/Core/Assets.xcassets/settings.imageset/Contents.json index aa6427af7..30cb38b07 100644 --- a/Core/Core/Assets.xcassets/settings.imageset/Contents.json +++ b/Core/Core/Assets.xcassets/settings.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "settingsIcon.svg", + "filename" : "icon-manage_accounts.svg", "idiom" : "universal" } ], diff --git a/Core/Core/Assets.xcassets/settings.imageset/icon-manage_accounts.svg b/Core/Core/Assets.xcassets/settings.imageset/icon-manage_accounts.svg new file mode 100644 index 000000000..5cf416fb2 --- /dev/null +++ b/Core/Core/Assets.xcassets/settings.imageset/icon-manage_accounts.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/Core/Core/Assets.xcassets/settings.imageset/settingsIcon.svg b/Core/Core/Assets.xcassets/settings.imageset/settingsIcon.svg deleted file mode 100644 index c1181ff8e..000000000 --- a/Core/Core/Assets.xcassets/settings.imageset/settingsIcon.svg +++ /dev/null @@ -1,4 +0,0 @@ - - - - diff --git a/Core/Core/Assets.xcassets/viewAll.imageset/Contents.json b/Core/Core/Assets.xcassets/viewAll.imageset/Contents.json new file mode 100644 index 000000000..b044a6ae9 --- /dev/null +++ b/Core/Core/Assets.xcassets/viewAll.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "viewAll.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/viewAll.imageset/viewAll.svg b/Core/Core/Assets.xcassets/viewAll.imageset/viewAll.svg new file mode 100644 index 000000000..da32ef8c1 --- /dev/null +++ b/Core/Core/Assets.xcassets/viewAll.imageset/viewAll.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/Core/Core/Configuration/Config/Config.swift b/Core/Core/Configuration/Config/Config.swift index 0c3aa5782..bd75f3f89 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 dashboard: DashboardConfig { get } var braze: BrazeConfig { get } var branch: BranchConfig { get } var segment: SegmentConfig { get } diff --git a/Core/Core/Configuration/Config/DashboardConfig.swift b/Core/Core/Configuration/Config/DashboardConfig.swift new file mode 100644 index 000000000..cd2b335b1 --- /dev/null +++ b/Core/Core/Configuration/Config/DashboardConfig.swift @@ -0,0 +1,34 @@ +// +// DashboardConfig.swift +// Core +// +// Created by  Stepanok Ivan on 23.04.2024. +// + +import Foundation + +public enum DashboardConfigType: String { + case gallery + case list +} + +private enum DashboardKeys: String, RawStringExtractable { + case dashboardType = "TYPE" +} + +public class DashboardConfig: NSObject { + public let type: DashboardConfigType + + init(dictionary: [String: AnyObject]) { + type = (dictionary[DashboardKeys.dashboardType] as? String).flatMap { + DashboardConfigType(rawValue: $0) + } ?? .gallery + } +} + +private let key = "DASHBOARD" +extension Config { + public var dashboard: DashboardConfig { + DashboardConfig(dictionary: self[key] as? [String: AnyObject] ?? [:]) + } +} diff --git a/Core/Core/Configuration/Config/DiscoveryConfig.swift b/Core/Core/Configuration/Config/DiscoveryConfig.swift index 893ea0ca9..d6d29de2b 100644 --- a/Core/Core/Configuration/Config/DiscoveryConfig.swift +++ b/Core/Core/Configuration/Config/DiscoveryConfig.swift @@ -36,6 +36,11 @@ public class DiscoveryWebviewConfig: NSObject { public class DiscoveryConfig: NSObject { public let type: DiscoveryConfigType public let webview: DiscoveryWebviewConfig + public var isWebViewConfigured: Bool { + get { + return type == .webview && webview.baseURL != nil + } + } init(dictionary: [String: AnyObject]) { type = (dictionary[DiscoveryKeys.discoveryType] as? String).flatMap { diff --git a/Core/Core/Data/Model/Data_Discovery.swift b/Core/Core/Data/Model/Data_Discovery.swift index cb8dd9be8..e5e4d01d7 100644 --- a/Core/Core/Data/Model/Data_Discovery.swift +++ b/Core/Core/Data/Model/Data_Discovery.swift @@ -108,14 +108,16 @@ public extension DataLayer.DiscoveryResponce { CourseItem(name: $0.name, org: $0.org, shortDescription: $0.shortDescription ?? "", imageURL: $0.media.image?.small ?? "", - isActive: nil, + hasAccess: true, courseStart: Date(iso8601: $0.start ?? ""), courseEnd: Date(iso8601: $0.end ?? ""), enrollmentStart: Date(iso8601: $0.enrollmentStart ?? ""), enrollmentEnd: Date(iso8601: $0.enrollmentEnd ?? ""), courseID: $0.courseID ?? "", numPages: pagination.numPages, - coursesCount: pagination.count) + coursesCount: pagination.count, + progressEarned: 0, + progressPossible: 0) }) return listReady } diff --git a/Core/Core/Data/Model/Data_Dashboard.swift b/Core/Core/Data/Model/Data_Enrollments.swift similarity index 93% rename from Core/Core/Data/Model/Data_Dashboard.swift rename to Core/Core/Data/Model/Data_Enrollments.swift index d39d8aa2d..527a69daa 100644 --- a/Core/Core/Data/Model/Data_Dashboard.swift +++ b/Core/Core/Data/Model/Data_Enrollments.swift @@ -1,5 +1,5 @@ // -// Data_Dashboard.swift +// Data_Enrollments.swift // Core // // Created by  Stepanok Ivan on 24.03.2023. @@ -29,7 +29,7 @@ public extension DataLayer { public let numPages: Int? public let currentPage: Int? public let start: Int? - public let results: [Result] + public let results: [Enrollment] enum CodingKeys: String, CodingKey { case next @@ -48,7 +48,7 @@ public extension DataLayer { numPages: Int?, currentPage: Int?, start: Int?, - results: [Result] + results: [Enrollment] ) { self.next = next self.previous = previous @@ -60,14 +60,15 @@ public extension DataLayer { } } - // MARK: - Result - struct Result: Codable { + // MARK: - Enrollment + struct Enrollment: Codable { public let auditAccessExpires: String? public let created: String public let mode: Mode public let isActive: Bool public let course: DashboardCourse public let courseModes: [CourseMode] + public let progress: CourseProgress? enum CodingKeys: String, CodingKey { case auditAccessExpires = "audit_access_expires" @@ -76,6 +77,7 @@ public extension DataLayer { case isActive = "is_active" case course case courseModes = "course_modes" + case progress = "course_progress" } public init( @@ -84,7 +86,8 @@ public extension DataLayer { mode: Mode, isActive: Bool, course: DashboardCourse, - courseModes: [CourseMode] + courseModes: [CourseMode], + progress: CourseProgress? ) { self.auditAccessExpires = auditAccessExpires self.created = created @@ -92,6 +95,7 @@ public extension DataLayer { self.isActive = isActive self.course = course self.courseModes = courseModes + self.progress = progress } } @@ -244,7 +248,7 @@ public extension DataLayer.CourseEnrollments { org: course.org, shortDescription: "", imageURL: fullImageURL, - isActive: true, + hasAccess: course.coursewareAccess.hasAccess, courseStart: course.start != nil ? Date(iso8601: course.start!) : nil, courseEnd: course.end != nil ? Date(iso8601: course.end!) : nil, enrollmentStart: course.start != nil @@ -255,7 +259,9 @@ public extension DataLayer.CourseEnrollments { : nil, courseID: course.id, numPages: enrollments.numPages ?? 1, - coursesCount: enrollments.count ?? 0 + coursesCount: enrollments.count ?? 0, + progressEarned: 0, + progressPossible: 0 ) } } diff --git a/Core/Core/Data/Model/Data_PrimaryEnrollment.swift b/Core/Core/Data/Model/Data_PrimaryEnrollment.swift new file mode 100644 index 000000000..1102bae78 --- /dev/null +++ b/Core/Core/Data/Model/Data_PrimaryEnrollment.swift @@ -0,0 +1,269 @@ +// +// Data_PrimaryEnrollment.swift +// Core +// +// Created by  Stepanok Ivan on 16.04.2024. +// + +import Foundation + +public extension DataLayer { + struct PrimaryEnrollment: Codable { + public let userTimezone: String? + public let enrollments: Enrollments? + public let primary: ActiveEnrollment? + + enum CodingKeys: String, CodingKey { + case userTimezone = "user_timezone" + case enrollments + case primary + } + + public init(userTimezone: String?, enrollments: Enrollments?, primary: ActiveEnrollment?) { + self.userTimezone = userTimezone + self.enrollments = enrollments + self.primary = primary + } + } + + // MARK: - Primary + struct ActiveEnrollment: Codable { + public let auditAccessExpires: Date? + public let created: String? + public let mode: String? + public let isActive: Bool? + public let course: DashboardCourse? + public let certificate: DataLayer.Certificate? + public let courseModes: [CourseMode]? + public let courseStatus: CourseStatus? + public let progress: CourseProgress? + public let courseAssignments: CourseAssignments? + + enum CodingKeys: String, CodingKey { + case auditAccessExpires = "audit_access_expires" + case created + case mode + case isActive = "is_active" + case course + case certificate + case courseModes = "course_modes" + case courseStatus = "course_status" + case progress = "course_progress" + case courseAssignments = "course_assignments" + } + + public init( + auditAccessExpires: Date?, + created: String?, + mode: String?, + isActive: Bool?, + course: DashboardCourse?, + certificate: DataLayer.Certificate?, + courseModes: [CourseMode]?, + courseStatus: CourseStatus?, + progress: CourseProgress?, + courseAssignments: CourseAssignments? + ) { + self.auditAccessExpires = auditAccessExpires + self.created = created + self.mode = mode + self.isActive = isActive + self.course = course + self.certificate = certificate + self.courseModes = courseModes + self.courseStatus = courseStatus + self.progress = progress + self.courseAssignments = courseAssignments + } + } + + // MARK: - CourseStatus + struct CourseStatus: Codable { + public let lastVisitedModuleID: String? + public let lastVisitedModulePath: [String]? + public let lastVisitedBlockID: String? + public let lastVisitedUnitDisplayName: String? + + enum CodingKeys: String, CodingKey { + case lastVisitedModuleID = "last_visited_module_id" + case lastVisitedModulePath = "last_visited_module_path" + case lastVisitedBlockID = "last_visited_block_id" + case lastVisitedUnitDisplayName = "last_visited_unit_display_name" + } + } + + // MARK: - CourseAssignments + struct CourseAssignments: Codable { + public let futureAssignments: [Assignment]? + public let pastAssignments: [Assignment]? + + enum CodingKeys: String, CodingKey { + case futureAssignments = "future_assignments" + case pastAssignments = "past_assignments" + } + + public init(futureAssignments: [Assignment]?, pastAssignments: [Assignment]?) { + self.futureAssignments = futureAssignments + self.pastAssignments = pastAssignments + } + } + + // MARK: - Assignment + struct Assignment: Codable { + public let assignmentType: String? + public let complete: Bool? + public let date: String? + public let dateType: String? + public let description: String? + public let learnerHasAccess: Bool? + public let link: String? + public let linkText: String? + public let title: String? + public let extraInfo: String? + public let firstComponentBlockID: String? + + enum CodingKeys: String, CodingKey { + case assignmentType = "assignment_type" + case complete + case date + case dateType = "date_type" + case description + case learnerHasAccess = "learner_has_access" + case link + case linkText = "link_text" + case title + case extraInfo = "extra_info" + case firstComponentBlockID = "first_component_block_id" + } + + public init( + assignmentType: String?, + complete: Bool?, + date: String?, + dateType: String?, + description: String?, + learnerHasAccess: Bool?, + link: String?, + linkText: String?, + title: String?, + extraInfo: String?, + firstComponentBlockID: String? + ) { + self.assignmentType = assignmentType + self.complete = complete + self.date = date + self.dateType = dateType + self.description = description + self.learnerHasAccess = learnerHasAccess + self.link = link + self.linkText = linkText + self.title = title + self.extraInfo = extraInfo + self.firstComponentBlockID = firstComponentBlockID + } + } + + // MARK: - CourseProgress + struct CourseProgress: Codable { + public let assignmentsCompleted: Int? + public let totalAssignmentsCount: Int? + + enum CodingKeys: String, CodingKey { + case assignmentsCompleted = "assignments_completed" + case totalAssignmentsCount = "total_assignments_count" + } + + public init(assignmentsCompleted: Int?, totalAssignmentsCount: Int?) { + self.assignmentsCompleted = assignmentsCompleted + self.totalAssignmentsCount = totalAssignmentsCount + } + } +} + +public extension DataLayer.PrimaryEnrollment { + + func domain(baseURL: String) -> PrimaryEnrollment { + let primaryCourse = createPrimaryCourse(from: self.primary, baseURL: baseURL) + let courses = createCourseItems(from: self.enrollments, baseURL: baseURL) + + return PrimaryEnrollment( + primaryCourse: primaryCourse, + courses: courses, + totalPages: enrollments?.numPages ?? 1, + count: enrollments?.count ?? 1 + ) + } + + private func createPrimaryCourse(from primary: DataLayer.ActiveEnrollment?, baseURL: String) -> PrimaryCourse? { + guard let primary = primary else { return nil } + + let futureAssignments = primary.courseAssignments?.futureAssignments ?? [] + let pastAssignments = primary.courseAssignments?.pastAssignments ?? [] + + return PrimaryCourse( + name: primary.course?.name ?? "", + org: primary.course?.org ?? "", + courseID: primary.course?.id ?? "", + hasAccess: primary.course?.coursewareAccess.hasAccess ?? true, + courseStart: primary.course?.start.flatMap { Date(iso8601: $0) }, + courseEnd: primary.course?.end.flatMap { Date(iso8601: $0) }, + courseBanner: baseURL + (primary.course?.media.courseImage?.url ?? ""), + futureAssignments: futureAssignments.map { createAssignment(from: $0) }, + pastAssignments: pastAssignments.map { createAssignment(from: $0) }, + progressEarned: primary.progress?.assignmentsCompleted ?? 0, + progressPossible: primary.progress?.totalAssignmentsCount ?? 0, + lastVisitedBlockID: primary.courseStatus?.lastVisitedBlockID, + resumeTitle: primary.courseStatus?.lastVisitedUnitDisplayName + ) + } + + private func createAssignment(from assignment: DataLayer.Assignment) -> Assignment { + return Assignment( + type: assignment.assignmentType ?? "", + title: assignment.title ?? "", + description: assignment.description ?? "", + date: Date(iso8601: assignment.date ?? ""), + complete: assignment.complete ?? false, + firstComponentBlockId: assignment.firstComponentBlockID + ) + } + + private func createCourseItems(from enrollments: DataLayer.Enrollments?, baseURL: String) -> [CourseItem] { + return enrollments?.results.map { + createCourseItem( + from: $0, + baseURL: baseURL, + numPages: enrollments?.numPages ?? 1, + count: enrollments?.count ?? 0 + ) + } ?? [] + } + + private func createCourseItem( + from enrollment: DataLayer.Enrollment, + baseURL: String, + numPages: Int, + count: Int + ) -> CourseItem { + let imageUrl = enrollment.course.media.courseImage?.url ?? "" + let encodedUrl = imageUrl.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? "" + let fullImageURL = baseURL + encodedUrl + + return CourseItem( + name: enrollment.course.name, + org: enrollment.course.org, + shortDescription: "", + imageURL: fullImageURL, + hasAccess: enrollment.course.coursewareAccess.hasAccess, + courseStart: enrollment.course.start.flatMap { Date(iso8601: $0) }, + courseEnd: enrollment.course.end.flatMap { Date(iso8601: $0) }, + enrollmentStart: enrollment.course.start.flatMap { Date(iso8601: $0) }, + enrollmentEnd: enrollment.course.end.flatMap { Date(iso8601: $0) }, + courseID: enrollment.course.id, + numPages: numPages, + coursesCount: count, + progressEarned: enrollment.progress?.assignmentsCompleted ?? 0, + progressPossible: enrollment.progress?.totalAssignmentsCount ?? 0 + ) + } +} diff --git a/Core/Core/Domain/Model/CourseItem.swift b/Core/Core/Domain/Model/CourseItem.swift index 9229417f1..67647e038 100644 --- a/Core/Core/Domain/Model/CourseItem.swift +++ b/Core/Core/Domain/Model/CourseItem.swift @@ -12,7 +12,7 @@ public struct CourseItem: Hashable { public let org: String public let shortDescription: String public let imageURL: String - public let isActive: Bool? + public let hasAccess: Bool public let courseStart: Date? public let courseEnd: Date? public let enrollmentStart: Date? @@ -20,24 +20,28 @@ public struct CourseItem: Hashable { public let courseID: String public let numPages: Int public let coursesCount: Int + public let progressEarned: Int + public let progressPossible: Int public init(name: String, org: String, shortDescription: String, imageURL: String, - isActive: Bool?, + hasAccess: Bool, courseStart: Date?, courseEnd: Date?, enrollmentStart: Date?, enrollmentEnd: Date?, courseID: String, numPages: Int, - coursesCount: Int) { + coursesCount: Int, + progressEarned: Int, + progressPossible: Int) { self.name = name self.org = org self.shortDescription = shortDescription self.imageURL = imageURL - self.isActive = isActive + self.hasAccess = hasAccess self.courseStart = courseStart self.courseEnd = courseEnd self.enrollmentStart = enrollmentStart @@ -45,5 +49,7 @@ public struct CourseItem: Hashable { self.courseID = courseID self.numPages = numPages self.coursesCount = coursesCount + self.progressEarned = progressEarned + self.progressPossible = progressPossible } } diff --git a/Core/Core/Domain/Model/PrimaryEnrollment.swift b/Core/Core/Domain/Model/PrimaryEnrollment.swift new file mode 100644 index 000000000..3f213aae5 --- /dev/null +++ b/Core/Core/Domain/Model/PrimaryEnrollment.swift @@ -0,0 +1,93 @@ +// +// PrimaryEnrollment.swift +// Core +// +// Created by  Stepanok Ivan on 16.04.2024. +// + +import Foundation + +public struct PrimaryEnrollment: Hashable { + public let primaryCourse: PrimaryCourse? + public var courses: [CourseItem] + public let totalPages: Int + public let count: Int + + public init(primaryCourse: PrimaryCourse?, courses: [CourseItem], totalPages: Int, count: Int) { + self.primaryCourse = primaryCourse + self.courses = courses + self.totalPages = totalPages + self.count = count + } +} + +public struct PrimaryCourse: Hashable { + public let name: String + public let org: String + public let courseID: String + public let hasAccess: Bool + public let courseStart: Date? + public let courseEnd: Date? + public let courseBanner: String + public let futureAssignments: [Assignment] + public let pastAssignments: [Assignment] + public let progressEarned: Int + public let progressPossible: Int + public let lastVisitedBlockID: String? + public let resumeTitle: String? + + public init( + name: String, + org: String, + courseID: String, + hasAccess: Bool, + courseStart: Date?, + courseEnd: Date?, + courseBanner: String, + futureAssignments: [Assignment], + pastAssignments: [Assignment], + progressEarned: Int, + progressPossible: Int, + lastVisitedBlockID: String?, + resumeTitle: String? + ) { + self.name = name + self.org = org + self.courseID = courseID + self.hasAccess = hasAccess + self.courseStart = courseStart + self.courseEnd = courseEnd + self.courseBanner = courseBanner + self.futureAssignments = futureAssignments + self.pastAssignments = pastAssignments + self.progressEarned = progressEarned + self.progressPossible = progressPossible + self.lastVisitedBlockID = lastVisitedBlockID + self.resumeTitle = resumeTitle + } +} + +public struct Assignment: Hashable { + public let type: String + public let title: String + public let description: String? + public let date: Date + public let complete: Bool + public let firstComponentBlockId: String? + + public init( + type: String, + title: String, + description: String?, + date: Date, + complete: Bool, + firstComponentBlockId: String? + ) { + self.type = type + self.title = title + self.description = description + self.date = date + self.complete = complete + self.firstComponentBlockId = firstComponentBlockId + } +} diff --git a/Core/Core/Extensions/DateExtension.swift b/Core/Core/Extensions/DateExtension.swift index bbdb6834b..7be0c84ec 100644 --- a/Core/Core/Extensions/DateExtension.swift +++ b/Core/Core/Extensions/DateExtension.swift @@ -75,6 +75,8 @@ public extension Date { } public enum DateStringStyle { + case courseStartsMonthDDYear + case courseEndsMonthDDYear case startDDMonthYear case endedMonthDay case mmddyy @@ -103,6 +105,10 @@ public extension Date { dateFormatter.locale = Locale(identifier: "en_US_POSIX") switch style { + case .courseStartsMonthDDYear: + dateFormatter.dateFormat = CoreLocalization.DateFormat.mmmDdYyyy + case .courseEndsMonthDDYear: + dateFormatter.dateFormat = CoreLocalization.DateFormat.mmmDdYyyy case .endedMonthDay: dateFormatter.dateFormat = CoreLocalization.DateFormat.mmmmDd case .mmddyy: @@ -122,6 +128,14 @@ public extension Date { let date = dateFormatter.string(from: self) switch style { + case .courseStartsMonthDDYear: + return CoreLocalization.Date.courseStarts + " " + date + case .courseEndsMonthDDYear: + if Date() < self { + return CoreLocalization.Date.courseEnds + " " + date + } else { + return CoreLocalization.Date.courseEnded + " " + date + } case .endedMonthDay: return CoreLocalization.Date.ended + " " + date case .mmddyy, .monthYear: diff --git a/Core/Core/Extensions/Notification.swift b/Core/Core/Extensions/Notification.swift index 9f792fb2a..d8e99731b 100644 --- a/Core/Core/Extensions/Notification.swift +++ b/Core/Core/Extensions/Notification.swift @@ -9,6 +9,7 @@ import Foundation public extension Notification.Name { static let onCourseEnrolled = Notification.Name("onCourseEnrolled") + static let onblockCompletionRequested = Notification.Name("onblockCompletionRequested") static let onTokenRefreshFailed = Notification.Name("onTokenRefreshFailed") static let onActualVersionReceived = Notification.Name("onActualVersionReceived") static let onAppUpgradeAccountSettingsTapped = Notification.Name("onAppUpgradeAccountSettingsTapped") diff --git a/Core/Core/Extensions/ViewExtension.swift b/Core/Core/Extensions/ViewExtension.swift index e791925ba..9db5e1087 100644 --- a/Core/Core/Extensions/ViewExtension.swift +++ b/Core/Core/Extensions/ViewExtension.swift @@ -247,7 +247,7 @@ public extension View { .onTapGesture(perform: action) } } - + func onTapBackground(enabled: Bool, _ action: @escaping () -> Void) -> some View { background( onTapBackgroundContent(enabled: enabled, action) diff --git a/Core/Core/SwiftGen/Assets.swift b/Core/Core/SwiftGen/Assets.swift index dfa0c9677..ea47e8ee4 100644 --- a/Core/Core/SwiftGen/Assets.swift +++ b/Core/Core/SwiftGen/Assets.swift @@ -74,6 +74,7 @@ public enum CoreAssets { public static let handouts = ImageAsset(name: "handouts") public static let dashboard = ImageAsset(name: "dashboard") public static let discovery = ImageAsset(name: "discovery") + public static let learn = ImageAsset(name: "learn") public static let profile = ImageAsset(name: "profile") public static let programs = ImageAsset(name: "programs") public static let addPhoto = ImageAsset(name: "addPhoto") @@ -102,10 +103,12 @@ public enum CoreAssets { public static let certificateBadge = ImageAsset(name: "certificateBadge") public static let check = ImageAsset(name: "check") public static let checkEmail = ImageAsset(name: "checkEmail") + public static let chevronRight = ImageAsset(name: "chevron_right") public static let clearInput = ImageAsset(name: "clearInput") public static let edit = ImageAsset(name: "edit") public static let favorite = ImageAsset(name: "favorite") public static let goodWork = ImageAsset(name: "goodWork") + public static let learnEmpty = ImageAsset(name: "learn_empty") public static let airmail = ImageAsset(name: "airmail") public static let defaultMail = ImageAsset(name: "defaultMail") public static let fastmail = ImageAsset(name: "fastmail") @@ -119,9 +122,11 @@ public enum CoreAssets { public static let noWifiMini = ImageAsset(name: "noWifiMini") public static let notAvaliable = ImageAsset(name: "notAvaliable") public static let playVideo = ImageAsset(name: "playVideo") + public static let resumeCourse = ImageAsset(name: "resumeCourse") public static let settings = ImageAsset(name: "settings") public static let star = ImageAsset(name: "star") public static let starOutline = ImageAsset(name: "star_outline") + public static let viewAll = ImageAsset(name: "viewAll") public static let warning = ImageAsset(name: "warning") public static let warningFilled = ImageAsset(name: "warning_filled") } diff --git a/Core/Core/SwiftGen/Strings.swift b/Core/Core/SwiftGen/Strings.swift index a5782d497..4bd41f9eb 100644 --- a/Core/Core/SwiftGen/Strings.swift +++ b/Core/Core/SwiftGen/Strings.swift @@ -79,6 +79,12 @@ public enum CoreLocalization { } } public enum Date { + /// Course Ended + public static let courseEnded = CoreLocalization.tr("Localizable", "DATE.COURSE_ENDED", fallback: "Course Ended") + /// Course Ends + public static let courseEnds = CoreLocalization.tr("Localizable", "DATE.COURSE_ENDS", fallback: "Course Ends") + /// Course Starts + public static let courseStarts = CoreLocalization.tr("Localizable", "DATE.COURSE_STARTS", fallback: "Course Starts") /// Ended public static let ended = CoreLocalization.tr("Localizable", "DATE.ENDED", fallback: "Ended") /// Just now @@ -93,6 +99,8 @@ public enum CoreLocalization { public static let mmmDdYyyy = CoreLocalization.tr("Localizable", "DATE_FORMAT.MMM_DD_YYYY", fallback: "MMM dd, yyyy") /// MMMM dd public static let mmmmDd = CoreLocalization.tr("Localizable", "DATE_FORMAT.MMMM_DD", fallback: "MMMM dd") + /// MMMM dd, yyyy + public static let mmmmDdYyyy = CoreLocalization.tr("Localizable", "DATE_FORMAT.MMMM_DD_YYYY", fallback: "MMMM dd, yyyy") } public enum DownloadManager { /// Completed @@ -136,6 +144,8 @@ public enum CoreLocalization { public static let discovery = CoreLocalization.tr("Localizable", "MAINSCREEN.DISCOVERY", fallback: "Discover") /// In developing public static let inDeveloping = CoreLocalization.tr("Localizable", "MAINSCREEN.IN_DEVELOPING", fallback: "In developing") + /// Learn + public static let learn = CoreLocalization.tr("Localizable", "MAINSCREEN.LEARN", fallback: "Learn") /// Profile public static let profile = CoreLocalization.tr("Localizable", "MAINSCREEN.PROFILE", fallback: "Profile") /// Programs diff --git a/Core/Core/View/Base/CourseCellView.swift b/Core/Core/View/Base/CourseCellView.swift index 6166b81a3..72f02b2bb 100644 --- a/Core/Core/View/Base/CourseCellView.swift +++ b/Core/Core/View/Base/CourseCellView.swift @@ -130,14 +130,17 @@ struct CourseCellView_Previews: PreviewProvider { org: "Edx", shortDescription: "", imageURL: "https://thumbs.dreamstime.com/b/logo-edx-samsung-tablet-edx-massive-open-online-course-mooc-provider-hosts-online-university-level-courses-wide-117763805.jpg", - isActive: true, + hasAccess: true, courseStart: Date(iso8601: "2032-05-26T12:13:14Z"), courseEnd: Date(iso8601: "2033-05-26T12:13:14Z"), enrollmentStart: nil, enrollmentEnd: nil, courseID: "1", numPages: 1, - coursesCount: 10) + coursesCount: 10, + progressEarned: 4, + progressPossible: 10 + ) static var previews: some View { ZStack { diff --git a/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift index bd558d197..ef300e279 100644 --- a/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift +++ b/Core/Core/View/Base/ScrollSlidingTabBar/ScrollSlidingTabBar.swift @@ -49,6 +49,9 @@ public struct ScrollSlidingTabBar: View { } .onTapGesture {} // Fix button tapable area bug – https://forums.developer.apple.com/forums/thread/745059 + .onAppear { + proxy.scrollTo(selection, anchor: .center) + } .onChange(of: selection) { newValue in withAnimation { proxy.scrollTo(newValue, anchor: .center) diff --git a/Core/Core/en.lproj/Localizable.strings b/Core/Core/en.lproj/Localizable.strings index 27196487a..44186571a 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -11,6 +11,7 @@ "MAINSCREEN.IN_DEVELOPING" = "In developing"; "MAINSCREEN.PROGRAMS" = "Programs"; "MAINSCREEN.PROFILE" = "Profile"; +"MAINSCREEN.LEARN" = "Learn"; "VIEW.SNACKBAR.TRY_AGAIN_BTN" = "Try Again"; @@ -44,6 +45,9 @@ "ERROR.RELOAD" = "Reload"; +"DATE.COURSE_STARTS" = "Course Starts"; +"DATE.COURSE_ENDS" = "Course Ends"; +"DATE.COURSE_ENDED" = "Course Ended"; "DATE.ENDED" = "Ended"; "DATE.START" = "Start"; "DATE.STARTED" = "Started"; @@ -65,6 +69,7 @@ "DATE_FORMAT.MMMM_DD" = "MMMM dd"; "DATE_FORMAT.MMM_DD_YYYY" = "MMM dd, yyyy"; +"DATE_FORMAT.MMMM_DD_YYYY" = "MMMM dd, yyyy"; "DOWNLOAD_MANAGER.DOWNLOAD" = "Download"; "DOWNLOAD_MANAGER.DOWNLOADED" = "Downloaded"; diff --git a/Core/Core/uk.lproj/Localizable.strings b/Core/Core/uk.lproj/Localizable.strings index 1da07ab04..1026b82e0 100644 --- a/Core/Core/uk.lproj/Localizable.strings +++ b/Core/Core/uk.lproj/Localizable.strings @@ -11,6 +11,7 @@ "MAINSCREEN.IN_DEVELOPING" = "В розробці"; "MAINSCREEN.PROGRAMS" = "Програми"; "MAINSCREEN.PROFILE" = "Профіль"; +"MAINSCREEN.LEARN" = "Навчання"; "VIEW.SNACKBAR.TRY_AGAIN_BTN" = "Спробувати ще"; diff --git a/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents b/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents index dda82f3ae..d25cbc5ad 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 @@ - + @@ -65,8 +65,8 @@ + - @@ -101,4 +101,4 @@ - + \ No newline at end of file diff --git a/Course/Course/Domain/CourseInteractor.swift b/Course/Course/Domain/CourseInteractor.swift index 1b01e7a59..dfec16d70 100644 --- a/Course/Course/Domain/CourseInteractor.swift +++ b/Course/Course/Domain/CourseInteractor.swift @@ -64,6 +64,7 @@ public class CourseInteractor: CourseInteractorProtocol { } public func blockCompletionRequest(courseID: String, blockID: String) async throws { + NotificationCenter.default.post(name: .onblockCompletionRequested, object: courseID) return try await repository.blockCompletionRequest(courseID: courseID, blockID: blockID) } diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index d79f12034..bc70c08f3 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -141,7 +141,7 @@ public struct CourseContainerView: View { courseDatesViewModel.resetEventState() } } - + private func backButton(containerWidth: CGFloat) -> some View { ZStack(alignment: .topLeading) { if !collapsed { @@ -338,6 +338,7 @@ struct CourseScreensView_Previews: PreviewProvider { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ), courseDatesViewModel: CourseDatesViewModel( @@ -350,7 +351,9 @@ struct CourseScreensView_Previews: PreviewProvider { courseName: "a", analytics: CourseAnalyticsMock() ), - courseID: "", title: "Title of Course") + courseID: "", + title: "Title of Course" + ) } } #endif diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index e8ce69707..f2678a915 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -14,13 +14,14 @@ public enum CourseTab: Int, CaseIterable, Identifiable { public var id: Int { rawValue } - case course case videos case discussion case dates case handounds +} +extension CourseTab { public var title: String { switch self { case .course: @@ -54,7 +55,7 @@ public enum CourseTab: Int, CaseIterable, Identifiable { public class CourseContainerViewModel: BaseCourseViewModel { - @Published public var selection: Int = CourseTab.course.rawValue + @Published public var selection: Int @Published var isShowProgress = true @Published var isShowRefresh = false @Published var courseStructure: CourseStructure? @@ -85,6 +86,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { let courseEnd: Date? let enrollmentStart: Date? let enrollmentEnd: Date? + let lastVisitedBlockID: String? var courseDownloadTasks: [DownloadDataTask] = [] private(set) var waitingDownloads: [CourseBlock]? @@ -109,7 +111,9 @@ public class CourseContainerViewModel: BaseCourseViewModel { courseEnd: Date?, enrollmentStart: Date?, enrollmentEnd: Date?, - coreAnalytics: CoreAnalytics + lastVisitedBlockID: String?, + coreAnalytics: CoreAnalytics, + selection: CourseTab = CourseTab.course ) { self.interactor = interactor self.authInteractor = authInteractor @@ -125,12 +129,43 @@ public class CourseContainerViewModel: BaseCourseViewModel { self.storage = storage self.userSettings = storage.userSettings self.isInternetAvaliable = connectivity.isInternetAvaliable + self.lastVisitedBlockID = lastVisitedBlockID self.coreAnalytics = coreAnalytics + self.selection = selection.rawValue super.init(manager: manager) addObservers() } + func openLastVisitedBlock() { + guard let continueWith = continueWith, + let courseStructure = courseStructure else { return } + let chapter = courseStructure.childs[continueWith.chapterIndex] + let sequential = chapter.childs[continueWith.sequentialIndex] + let continueUnit = sequential.childs[continueWith.verticalIndex] + + var continueBlock: CourseBlock? + continueUnit.childs.forEach { block in + if block.id == continueWith.lastVisitedBlockId { + continueBlock = block + } + } + + trackResumeCourseClicked( + blockId: continueBlock?.id ?? "" + ) + + router.showCourseUnit( + courseName: courseStructure.displayName, + blockId: continueBlock?.id ?? "", + courseID: courseStructure.id, + verticalIndex: continueWith.verticalIndex, + chapters: courseStructure.childs, + chapterIndex: continueWith.chapterIndex, + sequentialIndex: continueWith.sequentialIndex + ) + } + @MainActor func getCourseBlocks(courseID: String, withProgress: Bool = true) async { guard let courseStart, courseStart < Date() else { return } @@ -144,13 +179,10 @@ public class CourseContainerViewModel: BaseCourseViewModel { isShowProgress = false isShowRefresh = false if let courseStructure { - let continueWith = try await getResumeBlock( + try await getResumeBlock( courseID: courseID, courseStructure: courseStructure ) - withAnimation { - self.continueWith = continueWith - } } } else { courseStructure = try await interactor.getLoadedCourseBlocks(courseID: courseID) @@ -237,12 +269,22 @@ public class CourseContainerViewModel: BaseCourseViewModel { } @MainActor - private func getResumeBlock(courseID: String, courseStructure: CourseStructure) async throws -> ContinueWith? { - let result = try await interactor.resumeBlock(courseID: courseID) - return findContinueVertical( - blockID: result.blockID, - courseStructure: courseStructure - ) + private func getResumeBlock(courseID: String, courseStructure: CourseStructure) async throws { + if let lastVisitedBlockID { + self.continueWith = findContinueVertical( + blockID: lastVisitedBlockID, + courseStructure: courseStructure + ) + openLastVisitedBlock() + } else { + let result = try await interactor.resumeBlock(courseID: courseID) + withAnimation { + self.continueWith = findContinueVertical( + blockID: result.blockID, + courseStructure: courseStructure + ) + } + } } @MainActor diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 8895cb65c..936ba835e 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -100,28 +100,7 @@ public struct CourseOutlineView: View { data: continueWith, courseContinueUnit: continueUnit ) { - var continueBlock: CourseBlock? - continueUnit.childs.forEach { block in - if block.id == continueWith.lastVisitedBlockId { - continueBlock = block - } - } - - viewModel.trackResumeCourseClicked( - blockId: continueBlock?.id ?? "" - ) - - if let course = viewModel.courseStructure { - viewModel.router.showCourseUnit( - courseName: course.displayName, - blockId: continueBlock?.id ?? "", - courseID: course.id, - verticalIndex: continueWith.verticalIndex, - chapters: course.childs, - chapterIndex: continueWith.chapterIndex, - sequentialIndex: continueWith.sequentialIndex - ) - } + viewModel.openLastVisitedBlock() } } @@ -345,6 +324,7 @@ struct CourseOutlineView_Previews: PreviewProvider { courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) Task { diff --git a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift index 435539972..cd69ebbb3 100644 --- a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift @@ -40,6 +40,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) @@ -148,6 +149,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) @@ -207,6 +209,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) @@ -250,6 +253,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) @@ -290,6 +294,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) @@ -330,6 +335,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) @@ -462,6 +468,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure @@ -583,6 +590,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure @@ -704,6 +712,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure @@ -826,6 +835,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure @@ -956,6 +966,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure @@ -1086,6 +1097,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure @@ -1236,6 +1248,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, + lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure diff --git a/Dashboard/Dashboard.xcodeproj/project.pbxproj b/Dashboard/Dashboard.xcodeproj/project.pbxproj index 2a7b812fe..c6e7e4e52 100644 --- a/Dashboard/Dashboard.xcodeproj/project.pbxproj +++ b/Dashboard/Dashboard.xcodeproj/project.pbxproj @@ -7,14 +7,24 @@ objects = { /* Begin PBXBuildFile section */ - 027DB33328D8BDBA002B6862 /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027DB33228D8BDBA002B6862 /* DashboardView.swift */; }; + 0277241E2BCE9E1500C2908D /* PrimaryCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0277241D2BCE9E1500C2908D /* PrimaryCardView.swift */; }; + 027724202BCEA16C00C2908D /* ProgressLineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0277241F2BCEA16C00C2908D /* ProgressLineView.swift */; }; + 027DB33328D8BDBA002B6862 /* ListDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027DB33228D8BDBA002B6862 /* ListDashboardView.swift */; }; 027DB33928D8D9F6002B6862 /* DashboardEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027DB33828D8D9F6002B6862 /* DashboardEndpoint.swift */; }; 027DB33D28D8DB5E002B6862 /* DashboardRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027DB33C28D8DB5E002B6862 /* DashboardRepository.swift */; }; 027DB33F28D8E605002B6862 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 027DB33E28D8E605002B6862 /* Core.framework */; }; 027DB34328D8E89B002B6862 /* DashboardInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027DB34228D8E89B002B6862 /* DashboardInteractor.swift */; }; - 027DB34528D8E9D2002B6862 /* DashboardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027DB34428D8E9D2002B6862 /* DashboardViewModel.swift */; }; + 027DB34528D8E9D2002B6862 /* ListDashboardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027DB34428D8E9D2002B6862 /* ListDashboardViewModel.swift */; }; + 028895672BE3B34F00102D8C /* NoCoursesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028895662BE3B34E00102D8C /* NoCoursesView.swift */; }; + 02935B6F2BCEC91100B22F66 /* PrimaryCourseDashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02935B6E2BCEC91100B22F66 /* PrimaryCourseDashboardView.swift */; }; + 02935B712BCEC91F00B22F66 /* PrimaryCourseDashboardViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02935B702BCEC91F00B22F66 /* PrimaryCourseDashboardViewModel.swift */; }; + 02935B772BCFB2C100B22F66 /* CourseCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02935B762BCFB2C100B22F66 /* CourseCardView.swift */; }; 02A48B18295ACE200033D5E0 /* DashboardCoreModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 02A48B16295ACE200033D5E0 /* DashboardCoreModel.xcdatamodeld */; }; 02A48B1A295ACE3D0033D5E0 /* DashboardPersistenceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A48B19295ACE3D0033D5E0 /* DashboardPersistenceProtocol.swift */; }; + 02A98F112BD96814009B46BD /* DropDownMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A98F102BD96814009B46BD /* DropDownMenu.swift */; }; + 02A98F132BD96B9D009B46BD /* AllCoursesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A98F122BD96B9D009B46BD /* AllCoursesView.swift */; }; + 02A98F152BD96BC2009B46BD /* AllCoursesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A98F142BD96BC2009B46BD /* AllCoursesViewModel.swift */; }; + 02A98F172BD97886009B46BD /* CategoryFilterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A98F162BD97885009B46BD /* CategoryFilterView.swift */; }; 02A9A90B2978194100B55797 /* DashboardViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A9A90A2978194100B55797 /* DashboardViewModelTests.swift */; }; 02A9A90C2978194100B55797 /* Dashboard.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02EF39E728D89F560058F6BD /* Dashboard.framework */; platformFilter = ios; }; 02A9A92929781A4D00B55797 /* DashboardMock.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A9A92829781A4D00B55797 /* DashboardMock.generated.swift */; }; @@ -39,14 +49,24 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 027DB33228D8BDBA002B6862 /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; + 0277241D2BCE9E1500C2908D /* PrimaryCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryCardView.swift; sourceTree = ""; }; + 0277241F2BCEA16C00C2908D /* ProgressLineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressLineView.swift; sourceTree = ""; }; + 027DB33228D8BDBA002B6862 /* ListDashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListDashboardView.swift; sourceTree = ""; }; 027DB33828D8D9F6002B6862 /* DashboardEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardEndpoint.swift; sourceTree = ""; }; 027DB33C28D8DB5E002B6862 /* DashboardRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardRepository.swift; sourceTree = ""; }; 027DB33E28D8E605002B6862 /* Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 027DB34228D8E89B002B6862 /* DashboardInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardInteractor.swift; sourceTree = ""; }; - 027DB34428D8E9D2002B6862 /* DashboardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardViewModel.swift; sourceTree = ""; }; + 027DB34428D8E9D2002B6862 /* ListDashboardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ListDashboardViewModel.swift; sourceTree = ""; }; + 028895662BE3B34E00102D8C /* NoCoursesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoCoursesView.swift; sourceTree = ""; }; + 02935B6E2BCEC91100B22F66 /* PrimaryCourseDashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryCourseDashboardView.swift; sourceTree = ""; }; + 02935B702BCEC91F00B22F66 /* PrimaryCourseDashboardViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryCourseDashboardViewModel.swift; sourceTree = ""; }; + 02935B762BCFB2C100B22F66 /* CourseCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseCardView.swift; sourceTree = ""; }; 02A48B17295ACE200033D5E0 /* DashboardCoreModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = DashboardCoreModel.xcdatamodel; sourceTree = ""; }; 02A48B19295ACE3D0033D5E0 /* DashboardPersistenceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardPersistenceProtocol.swift; sourceTree = ""; }; + 02A98F102BD96814009B46BD /* DropDownMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropDownMenu.swift; sourceTree = ""; }; + 02A98F122BD96B9D009B46BD /* AllCoursesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllCoursesView.swift; sourceTree = ""; }; + 02A98F142BD96BC2009B46BD /* AllCoursesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllCoursesViewModel.swift; sourceTree = ""; }; + 02A98F162BD97885009B46BD /* CategoryFilterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoryFilterView.swift; sourceTree = ""; }; 02A9A9082978194100B55797 /* DashboardTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DashboardTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 02A9A90A2978194100B55797 /* DashboardViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardViewModelTests.swift; sourceTree = ""; }; 02A9A92829781A4D00B55797 /* DashboardMock.generated.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DashboardMock.generated.swift; sourceTree = ""; }; @@ -109,6 +129,19 @@ path = Persistence; sourceTree = ""; }; + 0277241C2BCE9DF300C2908D /* Elements */ = { + isa = PBXGroup; + children = ( + 0277241D2BCE9E1500C2908D /* PrimaryCardView.swift */, + 02A98F102BD96814009B46BD /* DropDownMenu.swift */, + 02A98F162BD97885009B46BD /* CategoryFilterView.swift */, + 02935B762BCFB2C100B22F66 /* CourseCardView.swift */, + 0277241F2BCEA16C00C2908D /* ProgressLineView.swift */, + 028895662BE3B34E00102D8C /* NoCoursesView.swift */, + ); + path = Elements; + sourceTree = ""; + }; 027DB33628D8D851002B6862 /* Domain */ = { isa = PBXGroup; children = ( @@ -181,8 +214,13 @@ 02F6EF3F28D9ECA200835477 /* Presentation */ = { isa = PBXGroup; children = ( - 027DB33228D8BDBA002B6862 /* DashboardView.swift */, - 027DB34428D8E9D2002B6862 /* DashboardViewModel.swift */, + 0277241C2BCE9DF300C2908D /* Elements */, + 02935B6E2BCEC91100B22F66 /* PrimaryCourseDashboardView.swift */, + 02935B702BCEC91F00B22F66 /* PrimaryCourseDashboardViewModel.swift */, + 02A98F122BD96B9D009B46BD /* AllCoursesView.swift */, + 02A98F142BD96BC2009B46BD /* AllCoursesViewModel.swift */, + 027DB33228D8BDBA002B6862 /* ListDashboardView.swift */, + 027DB34428D8E9D2002B6862 /* ListDashboardViewModel.swift */, 02F3BFE029252FCB0051930C /* DashboardRouter.swift */, 02F175322A4DABBF0019CD70 /* DashboardAnalytics.swift */, ); @@ -455,14 +493,24 @@ buildActionMask = 2147483647; files = ( 02A48B1A295ACE3D0033D5E0 /* DashboardPersistenceProtocol.swift in Sources */, + 028895672BE3B34F00102D8C /* NoCoursesView.swift in Sources */, + 02935B6F2BCEC91100B22F66 /* PrimaryCourseDashboardView.swift in Sources */, + 02A98F172BD97886009B46BD /* CategoryFilterView.swift in Sources */, 027DB33D28D8DB5E002B6862 /* DashboardRepository.swift in Sources */, + 02935B712BCEC91F00B22F66 /* PrimaryCourseDashboardViewModel.swift in Sources */, + 02A98F112BD96814009B46BD /* DropDownMenu.swift in Sources */, 02A48B18295ACE200033D5E0 /* DashboardCoreModel.xcdatamodeld in Sources */, + 02935B772BCFB2C100B22F66 /* CourseCardView.swift in Sources */, 02F175332A4DABBF0019CD70 /* DashboardAnalytics.swift in Sources */, - 027DB34528D8E9D2002B6862 /* DashboardViewModel.swift in Sources */, - 027DB33328D8BDBA002B6862 /* DashboardView.swift in Sources */, + 027DB34528D8E9D2002B6862 /* ListDashboardViewModel.swift in Sources */, + 0277241E2BCE9E1500C2908D /* PrimaryCardView.swift in Sources */, + 027DB33328D8BDBA002B6862 /* ListDashboardView.swift in Sources */, + 02A98F152BD96BC2009B46BD /* AllCoursesViewModel.swift in Sources */, + 02A98F132BD96B9D009B46BD /* AllCoursesView.swift in Sources */, 02F3BFE129252FCB0051930C /* DashboardRouter.swift in Sources */, 027DB33928D8D9F6002B6862 /* DashboardEndpoint.swift in Sources */, 02F6EF4828D9ED8300835477 /* Strings.swift in Sources */, + 027724202BCEA16C00C2908D /* ProgressLineView.swift in Sources */, 97E7DF0B2B7A3EAF00A2A09B /* CourseEnrollmentsMock.swift in Sources */, 027DB34328D8E89B002B6862 /* DashboardInteractor.swift in Sources */, ); diff --git a/Dashboard/Dashboard/Data/DashboardRepository.swift b/Dashboard/Dashboard/Data/DashboardRepository.swift index 65816872f..df8cea243 100644 --- a/Dashboard/Dashboard/Data/DashboardRepository.swift +++ b/Dashboard/Dashboard/Data/DashboardRepository.swift @@ -9,8 +9,11 @@ import Foundation import Core public protocol DashboardRepositoryProtocol { - func getMyCourses(page: Int) async throws -> [CourseItem] - func getMyCoursesOffline() throws -> [CourseItem] + func getEnrollments(page: Int) async throws -> [CourseItem] + func getEnrollmentsOffline() throws -> [CourseItem] + func getPrimaryEnrollment(pageSize: Int) async throws -> PrimaryEnrollment + func getPrimaryEnrollmentOffline() async throws -> PrimaryEnrollment + func getAllCourses(filteredBy: String, page: Int) async throws -> PrimaryEnrollment } public class DashboardRepository: DashboardRepositoryProtocol { @@ -27,39 +30,58 @@ public class DashboardRepository: DashboardRepositoryProtocol { self.persistence = persistence } - public func getMyCourses(page: Int) async throws -> [CourseItem] { + public func getEnrollments(page: Int) async throws -> [CourseItem] { let result = try await api.requestData( - DashboardEndpoint.getMyCourses(username: storage.user?.username ?? "", page: page) + DashboardEndpoint.getEnrollments(username: storage.user?.username ?? "", page: page) ) .mapResponse(DataLayer.CourseEnrollments.self) .domain(baseURL: config.baseURL.absoluteString) - persistence.saveMyCourses(items: result) + persistence.saveEnrollments(items: result) return result } - public func getMyCoursesOffline() throws -> [CourseItem] { - return try persistence.loadMyCourses() + public func getEnrollmentsOffline() throws -> [CourseItem] { + return try persistence.loadEnrollments() } + public func getPrimaryEnrollment(pageSize: Int) async throws -> PrimaryEnrollment { + let result = try await api.requestData( + DashboardEndpoint.getPrimaryEnrollment( + username: storage.user?.username ?? "", + pageSize: pageSize + ) + ) + .mapResponse(DataLayer.PrimaryEnrollment.self) + .domain(baseURL: config.baseURL.absoluteString) + persistence.savePrimaryEnrollment(enrollments: result) + return result + } + + public func getPrimaryEnrollmentOffline() async throws -> PrimaryEnrollment { + return try persistence.loadPrimaryEnrollment() + } + + public func getAllCourses(filteredBy: String, page: Int) async throws -> PrimaryEnrollment { + let result = try await api.requestData( + DashboardEndpoint.getAllCourses( + username: storage.user?.username ?? "", + filteredBy: filteredBy, + page: page + ) + ) + .mapResponse(DataLayer.PrimaryEnrollment.self) + .domain(baseURL: config.baseURL.absoluteString) + return result + } } +// swiftlint:disable all // 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] { + func getEnrollments(page: Int) async throws -> [CourseItem] { var models: [CourseItem] = [] for i in 0...10 { models.append( @@ -68,20 +90,104 @@ class DashboardRepositoryMock: DashboardRepositoryProtocol { org: "Organization", shortDescription: "shortDescription", imageURL: "", - isActive: true, + hasAccess: true, courseStart: nil, courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, courseID: "course_id_\(i)", numPages: 1, - coursesCount: 0 + coursesCount: 0, + progressEarned: 0, + progressPossible: 0 ) ) } return models } - func getMyCoursesOffline() throws -> [CourseItem] { return [] } + func getEnrollmentsOffline() throws -> [CourseItem] { return [] } + + public func getPrimaryEnrollment(pageSize: Int) async throws -> PrimaryEnrollment { + + var courses: [CourseItem] = [] + for i in 0...10 { + courses.append( + CourseItem( + name: "Course name \(i)", + org: "Organization", + shortDescription: "shortDescription", + imageURL: "", + hasAccess: true, + courseStart: nil, + courseEnd: nil, + enrollmentStart: nil, + enrollmentEnd: nil, + courseID: "course_id_\(i)", + numPages: 1, + coursesCount: 0, + progressEarned: 4, + progressPossible: 10 + ) + ) + } + + let futureAssignment = Assignment( + type: "Final Exam", + title: "Subsection 3", + description: "", + date: Date(), + complete: false, + firstComponentBlockId: nil + ) + + let primaryCourse = PrimaryCourse( + name: "Primary Course", + org: "Organization", + courseID: "123", + hasAccess: true, + courseStart: Date(), + courseEnd: Date(), + courseBanner: "https://thumbs.dreamstime.com/b/logo-edx-samsung-tablet-edx-massive-open-online-course-mooc-provider-hosts-online-university-level-courses-wide-117763805.jpg", + futureAssignments: [futureAssignment], + pastAssignments: [futureAssignment], + progressEarned: 2, + progressPossible: 5, + lastVisitedBlockID: nil, + resumeTitle: nil + ) + return PrimaryEnrollment(primaryCourse: primaryCourse, courses: courses, totalPages: 1, count: 1) + } + + func getPrimaryEnrollmentOffline() async throws -> Core.PrimaryEnrollment { + Core.PrimaryEnrollment(primaryCourse: nil, courses: [], totalPages: 1, count: 1) + } + + func getAllCourses(filteredBy: String, page: Int) async throws -> Core.PrimaryEnrollment { + var courses: [CourseItem] = [] + for i in 0...10 { + courses.append( + CourseItem( + name: "Course name \(i)", + org: "Organization", + shortDescription: "shortDescription", + imageURL: "", + hasAccess: true, + courseStart: nil, + courseEnd: nil, + enrollmentStart: nil, + enrollmentEnd: nil, + courseID: "course_id_\(i)", + numPages: 1, + coursesCount: 0, + progressEarned: 4, + progressPossible: 10 + ) + ) + } + + return PrimaryEnrollment(primaryCourse: nil, courses: courses, totalPages: 1, count: 1) + } } #endif +// swiftlint:enable all diff --git a/Dashboard/Dashboard/Data/Network/DashboardEndpoint.swift b/Dashboard/Dashboard/Data/Network/DashboardEndpoint.swift index 1d6845214..cda65b250 100644 --- a/Dashboard/Dashboard/Data/Network/DashboardEndpoint.swift +++ b/Dashboard/Dashboard/Data/Network/DashboardEndpoint.swift @@ -10,18 +10,24 @@ import Core import Alamofire enum DashboardEndpoint: EndPointType { - case getMyCourses(username: String, page: Int) + case getEnrollments(username: String, page: Int) + case getPrimaryEnrollment(username: String, pageSize: Int) + case getAllCourses(username: String, filteredBy: String, page: Int) var path: String { switch self { - case let .getMyCourses(username, _): + case let .getEnrollments(username, _): return "/api/mobile/v3/users/\(username)/course_enrollments" + case let .getPrimaryEnrollment(username, _): + return "/api/mobile/v4/users/\(username)/course_enrollments" + case let .getAllCourses(username, _, _): + return "/api/mobile/v4/users/\(username)/course_enrollments" } } var httpMethod: HTTPMethod { switch self { - case .getMyCourses: + case .getEnrollments, .getPrimaryEnrollment, .getAllCourses: return .get } } @@ -32,11 +38,26 @@ enum DashboardEndpoint: EndPointType { var task: HTTPTask { switch self { - case let .getMyCourses(_, page): + case let .getEnrollments(_, page): let params: Parameters = [ "page": page ] return .requestParameters(parameters: params, encoding: URLEncoding.queryString) + + case let .getPrimaryEnrollment(_, pageSize): + let params: Parameters = [ + "page_size": pageSize + ] + return .requestParameters(parameters: params, encoding: URLEncoding.queryString) + + case let .getAllCourses(_, filteredBy, page): + let params: Parameters = [ + "page_size": 10, + "status": filteredBy, + "requested_fields": "course_progress", + "page": page + ] + return .requestParameters(parameters: params, encoding: URLEncoding.queryString) } } } diff --git a/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents b/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents index eeee515fe..d3bea41fc 100644 --- a/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents +++ b/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents @@ -1,5 +1,18 @@ - + + + + + + + + + + + + + + @@ -9,10 +22,13 @@ + + + @@ -20,4 +36,30 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Dashboard/Dashboard/Data/Persistence/DashboardPersistenceProtocol.swift b/Dashboard/Dashboard/Data/Persistence/DashboardPersistenceProtocol.swift index 14bad2aaa..3747d2c8e 100644 --- a/Dashboard/Dashboard/Data/Persistence/DashboardPersistenceProtocol.swift +++ b/Dashboard/Dashboard/Data/Persistence/DashboardPersistenceProtocol.swift @@ -9,8 +9,10 @@ import CoreData import Core public protocol DashboardPersistenceProtocol { - func loadMyCourses() throws -> [CourseItem] - func saveMyCourses(items: [CourseItem]) + func loadEnrollments() throws -> [CourseItem] + func saveEnrollments(items: [CourseItem]) + func loadPrimaryEnrollment() throws -> PrimaryEnrollment + func savePrimaryEnrollment(enrollments: PrimaryEnrollment) } public final class DashboardBundle { diff --git a/Dashboard/Dashboard/Domain/DashboardInteractor.swift b/Dashboard/Dashboard/Domain/DashboardInteractor.swift index 8e84d847b..0d55f0a4e 100644 --- a/Dashboard/Dashboard/Domain/DashboardInteractor.swift +++ b/Dashboard/Dashboard/Domain/DashboardInteractor.swift @@ -10,8 +10,11 @@ import Core //sourcery: AutoMockable public protocol DashboardInteractorProtocol { - func getMyCourses(page: Int) async throws -> [CourseItem] - func discoveryOffline() throws -> [CourseItem] + func getEnrollments(page: Int) async throws -> [CourseItem] + func getEnrollmentsOffline() throws -> [CourseItem] + func getPrimaryEnrollment(pageSize: Int) async throws -> PrimaryEnrollment + func getPrimaryEnrollmentOffline() async throws -> PrimaryEnrollment + func getAllCourses(filteredBy: String, page: Int) async throws -> PrimaryEnrollment } public class DashboardInteractor: DashboardInteractorProtocol { @@ -23,12 +26,24 @@ public class DashboardInteractor: DashboardInteractorProtocol { } @discardableResult - public func getMyCourses(page: Int) async throws -> [CourseItem] { - return try await repository.getMyCourses(page: page) + public func getEnrollments(page: Int) async throws -> [CourseItem] { + return try await repository.getEnrollments(page: page) } - public func discoveryOffline() throws -> [CourseItem] { - return try repository.getMyCoursesOffline() + public func getEnrollmentsOffline() throws -> [CourseItem] { + return try repository.getEnrollmentsOffline() + } + + public func getPrimaryEnrollment(pageSize: Int) async throws -> PrimaryEnrollment { + return try await repository.getPrimaryEnrollment(pageSize: pageSize) + } + + public func getPrimaryEnrollmentOffline() async throws -> PrimaryEnrollment { + return try await repository.getPrimaryEnrollmentOffline() + } + + public func getAllCourses(filteredBy: String, page: Int) async throws -> PrimaryEnrollment { + return try await repository.getAllCourses(filteredBy: filteredBy, page: page) } } diff --git a/Dashboard/Dashboard/Presentation/AllCoursesView.swift b/Dashboard/Dashboard/Presentation/AllCoursesView.swift new file mode 100644 index 000000000..332ae13c4 --- /dev/null +++ b/Dashboard/Dashboard/Presentation/AllCoursesView.swift @@ -0,0 +1,211 @@ +// +// AllCoursesView.swift +// Dashboard +// +// Created by  Stepanok Ivan on 24.04.2024. +// + +import SwiftUI +import Core +import Theme + +public struct AllCoursesView: View { + + @ObservedObject + private var viewModel: AllCoursesViewModel + private let router: DashboardRouter + @Environment (\.isHorizontal) private var isHorizontal + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + + public init(viewModel: AllCoursesViewModel, router: DashboardRouter) { + self.viewModel = viewModel + self.router = router + } + + public var body: some View { + GeometryReader { proxy in + ZStack(alignment: .top) { + VStack { + BackNavigationButton( + color: Theme.Colors.textPrimary, + action: { + router.back() + } + ) + .backViewStyle() + .padding(.top, isHorizontal ? 32 : 16) + .padding(.leading, 7) + + }.frame(minWidth: 0, + maxWidth: .infinity, + alignment: .topLeading) + .zIndex(1) + + if let myEnrollments = viewModel.myEnrollments, + myEnrollments.courses.isEmpty, + !viewModel.fetchInProgress, + !viewModel.refresh { + NoCoursesView(selectedMenu: viewModel.selectedMenu) + } + // MARK: - Page body + VStack(alignment: .center) { + learnTitleAndSearch() + .frameLimit(width: proxy.size.width) + RefreshableScrollViewCompat(action: { + await viewModel.getCourses(page: 1, refresh: true) + }) { + CategoryFilterView(selectedOption: $viewModel.selectedMenu) + .disabled(viewModel.fetchInProgress) + .frameLimit(width: proxy.size.width) + if let myEnrollments = viewModel.myEnrollments { + LazyVGrid(columns: columns(), spacing: 15) { + ForEach( + Array(myEnrollments.courses.enumerated()), + id: \.offset + ) { index, course in + Button(action: { + viewModel.trackDashboardCourseClicked( + courseID: course.courseID, + courseName: course.name + ) + router.showCourseScreens( + courseID: course.courseID, + hasAccess: course.hasAccess, + courseStart: course.courseStart, + courseEnd: course.courseEnd, + enrollmentStart: course.enrollmentStart, + enrollmentEnd: course.enrollmentEnd, + title: course.name, + showDates: false, + lastVisitedBlockID: nil + ) + }, label: { + CourseCardView( + courseName: course.name, + courseImage: course.imageURL, + progressEarned: course.progressEarned, + progressPossible: course.progressPossible, + courseStartDate: course.courseStart, + courseEndDate: course.courseEnd, + hasAccess: course.hasAccess, + showProgress: true + ).padding(8) + }) + .accessibilityIdentifier("course_item") + .onAppear { + Task { + await viewModel.getMyCoursesPagination(index: index) + } + } + } + } + .padding(10) + .frameLimit(width: proxy.size.width) + } + // MARK: - ProgressBar + if viewModel.nextPage <= viewModel.totalPages, !viewModel.refresh { + VStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 20) + }.frame(maxWidth: .infinity, + maxHeight: .infinity) + } + VStack {}.frame(height: 40) + } + .accessibilityAction {} + } + .padding(.top, 8) + + // MARK: - Offline mode SnackBar + OfflineSnackBarView( + connectivity: viewModel.connectivity, + reloadAction: { + await viewModel.getCourses(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 + } + } + } + } + .onFirstAppear { + Task { + await viewModel.getCourses(page: 1) + } + } + .onChange(of: viewModel.selectedMenu) { _ in + Task { + viewModel.myEnrollments?.courses = [] + await viewModel.getCourses(page: 1, refresh: false) + } + } + .background( + Theme.Colors.background + .ignoresSafeArea() + ) + .navigationBarBackButtonHidden(true) + .navigationBarHidden(true) + .navigationTitle(DashboardLocalization.Learn.allCourses) + } + } + + private func columns() -> [GridItem] { + isHorizontal || idiom == .pad + ? [ + GridItem(.flexible()), + GridItem(.flexible()), + GridItem(.flexible()) + ] + : [ + GridItem(.flexible()), + GridItem(.flexible()) + ] + } + + private func learnTitleAndSearch() -> some View { + HStack(alignment: .center) { + Text(DashboardLocalization.Learn.allCourses) + .font(Theme.Fonts.displaySmall) + .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("all_courses_header_text") + Spacer() + } + .padding(.horizontal, 20) + .padding(.top, 20) + .padding(.bottom, 10) + .accessibilityElement(children: .ignore) + .accessibilityLabel(DashboardLocalization.Learn.allCourses) + } +} + +#if DEBUG +struct AllCoursesView_Previews: PreviewProvider { + static var previews: some View { + let vm = AllCoursesViewModel( + interactor: DashboardInteractor.mock, + connectivity: Connectivity(), + analytics: DashboardAnalyticsMock() + ) + + AllCoursesView(viewModel: vm, router: DashboardRouterMock()) + .preferredColorScheme(.light) + .previewDisplayName("AllCoursesView Light") + + AllCoursesView(viewModel: vm, router: DashboardRouterMock()) + .preferredColorScheme(.dark) + .previewDisplayName("AllCoursesView Dark") + } +} +#endif diff --git a/Dashboard/Dashboard/Presentation/AllCoursesViewModel.swift b/Dashboard/Dashboard/Presentation/AllCoursesViewModel.swift new file mode 100644 index 000000000..750c0936b --- /dev/null +++ b/Dashboard/Dashboard/Presentation/AllCoursesViewModel.swift @@ -0,0 +1,105 @@ +// +// AllCoursesViewModel.swift +// Dashboard +// +// Created by  Stepanok Ivan on 24.04.2024. +// + +import Foundation +import Core +import SwiftUI +import Combine + +public class AllCoursesViewModel: ObservableObject { + + var nextPage = 1 + var totalPages = 1 + @Published private(set) var fetchInProgress = false + @Published private(set) var refresh = false + @Published var selectedMenu: CategoryOption = .all + + @Published var myEnrollments: PrimaryEnrollment? + @Published var showError: Bool = false + var errorMessage: String? { + didSet { + withAnimation { + showError = errorMessage != nil + } + } + } + + let connectivity: ConnectivityProtocol + private let interactor: DashboardInteractorProtocol + private let analytics: DashboardAnalytics + private var onCourseEnrolledCancellable: AnyCancellable? + + public init( + interactor: DashboardInteractorProtocol, + connectivity: ConnectivityProtocol, + analytics: DashboardAnalytics + ) { + self.interactor = interactor + self.connectivity = connectivity + self.analytics = analytics + + onCourseEnrolledCancellable = NotificationCenter.default + .publisher(for: .onCourseEnrolled) + .sink { [weak self] _ in + guard let self = self else { return } + Task { + await self.getCourses(page: 1, refresh: true) + } + } + } + + @MainActor + public func getCourses(page: Int, refresh: Bool = false) async { + self.refresh = refresh + do { + if refresh || page == 1 { + fetchInProgress = true + myEnrollments?.courses = [] + nextPage = 1 + myEnrollments = try await interactor.getAllCourses(filteredBy: selectedMenu.status, page: page) + self.totalPages = myEnrollments?.totalPages ?? 1 + } else { + fetchInProgress = true + myEnrollments?.courses += try await interactor.getAllCourses( + filteredBy: selectedMenu.status, page: page + ).courses + } + self.nextPage += 1 + totalPages = myEnrollments?.totalPages ?? 1 + fetchInProgress = false + self.refresh = false + } catch let error { + fetchInProgress = false + self.refresh = false + if error is NoCachedDataError { + errorMessage = CoreLocalization.Error.noCachedData + } else { + errorMessage = CoreLocalization.Error.unknownError + } + } + } + + @MainActor + public func getMyCoursesPagination(index: Int) async { + guard let courses = myEnrollments?.courses else { return } + if !fetchInProgress { + if totalPages > 1 { + if index == courses.count - 3 { + if totalPages != 1 { + if nextPage <= totalPages { + await getCourses(page: self.nextPage) + } + } + } + } + } + } + + func trackDashboardCourseClicked(courseID: String, courseName: String) { + analytics.dashboardCourseClicked(courseID: courseID, courseName: courseName) + } +} diff --git a/Dashboard/Dashboard/Presentation/DashboardRouter.swift b/Dashboard/Dashboard/Presentation/DashboardRouter.swift index 40bc86c41..0e66ff2a8 100644 --- a/Dashboard/Dashboard/Presentation/DashboardRouter.swift +++ b/Dashboard/Dashboard/Presentation/DashboardRouter.swift @@ -11,12 +11,20 @@ import Core public protocol DashboardRouter: BaseRouter { func showCourseScreens(courseID: String, - isActive: Bool?, + hasAccess: Bool?, courseStart: Date?, courseEnd: Date?, enrollmentStart: Date?, enrollmentEnd: Date?, - title: String) + title: String, + showDates: Bool, + lastVisitedBlockID: String?) + + func showAllCourses(courses: [CourseItem]) + + func showDiscoverySearch(searchQuery: String?) + + func showSettings() } @@ -27,12 +35,19 @@ public class DashboardRouterMock: BaseRouterMock, DashboardRouter { public override init() {} public func showCourseScreens(courseID: String, - isActive: Bool?, + hasAccess: Bool?, courseStart: Date?, courseEnd: Date?, enrollmentStart: Date?, enrollmentEnd: Date?, - title: String) {} + title: String, + showDates: Bool, + lastVisitedBlockID: String?) {} + + public func showAllCourses(courses: [CourseItem]) {} + + public func showDiscoverySearch(searchQuery: String?) {} + public func showSettings() {} } #endif diff --git a/Dashboard/Dashboard/Presentation/Elements/CategoryFilterView.swift b/Dashboard/Dashboard/Presentation/Elements/CategoryFilterView.swift new file mode 100644 index 000000000..c01444840 --- /dev/null +++ b/Dashboard/Dashboard/Presentation/Elements/CategoryFilterView.swift @@ -0,0 +1,91 @@ +// +// CategoryFilterView.swift +// Dashboard +// +// Created by  Stepanok Ivan on 24.04.2024. +// + +import Theme +import Core +import SwiftUI + +enum CategoryOption: String, CaseIterable { + case all + case inProgress + case completed + case expired + + var status: String { + switch self { + case .all: + "all" + case .inProgress: + "in_progress" + case .completed: + "completed" + case .expired: + "expired" + } + } + + var text: String { + switch self { + case .all: + DashboardLocalization.Learn.Category.all + case .inProgress: + DashboardLocalization.Learn.Category.inProgress + case .completed: + DashboardLocalization.Learn.Category.completed + case .expired: + DashboardLocalization.Learn.Category.expired + } + } +} + +struct CategoryFilterView: View { + @Binding var selectedOption: CategoryOption + @Environment (\.colorScheme) var colorScheme + + var body: some View { + ScrollView(.horizontal) { + HStack(spacing: 8) { + ForEach(Array(CategoryOption.allCases.enumerated()), id: \.offset) { index, option in + Button(action: { + selectedOption = option + }, + label: { + HStack { + Text(option.text) + .font(Theme.Fonts.titleSmall) + .foregroundColor( + option == selectedOption ? Theme.Colors.white : ( + colorScheme == .light ? Theme.Colors.accentColor : .white + ) + ) + } + .padding(.horizontal, 17) + .padding(.vertical, 8) + .background { + ZStack { + RoundedRectangle(cornerRadius: 20) + .foregroundStyle( + option == selectedOption + ? Theme.Colors.accentColor + : Theme.Colors.cardViewBackground + ) + RoundedRectangle(cornerRadius: 20) + .stroke( + colorScheme == .light ? Theme.Colors.accentColor : .clear, + style: .init(lineWidth: 1) + ) + } + .padding(.vertical, 1) + } + }) + .padding(.leading, index == 0 ? 16 : 0) + } + } + .fixedSize() + } + } +} diff --git a/Dashboard/Dashboard/Presentation/Elements/CourseCardView.swift b/Dashboard/Dashboard/Presentation/Elements/CourseCardView.swift new file mode 100644 index 000000000..53a0911db --- /dev/null +++ b/Dashboard/Dashboard/Presentation/Elements/CourseCardView.swift @@ -0,0 +1,125 @@ +// +// CourseCardView.swift +// Dashboard +// +// Created by  Stepanok Ivan on 17.04.2024. +// + +import SwiftUI +import Theme +import Kingfisher +import Core + +struct CourseCardView: View { + + private let courseName: String + private let courseImage: String + private let progressEarned: Int + private let progressPossible: Int + private let courseStartDate: Date? + private let courseEndDate: Date? + private let hasAccess: Bool + private let showProgress: Bool + + init( + courseName: String, + courseImage: String, + progressEarned: Int, + progressPossible: Int, + courseStartDate: Date?, + courseEndDate: Date?, + hasAccess: Bool, + showProgress: Bool + ) { + self.courseName = courseName + self.courseImage = courseImage + self.progressEarned = progressEarned + self.progressPossible = progressPossible + self.courseStartDate = courseStartDate + self.courseEndDate = courseEndDate + self.hasAccess = hasAccess + self.showProgress = showProgress + } + + var body: some View { + ZStack(alignment: .topTrailing) { + VStack(alignment: .leading, spacing: 0) { + courseBanner + if showProgress { + ProgressLineView( + progressEarned: progressEarned, + progressPossible: progressPossible, + height: 4 + ) + } + courseTitle + } + if !hasAccess { + ZStack(alignment: .center) { + Circle() + .foregroundStyle(Theme.Colors.primaryHeaderColor) + .opacity(0.7) + .frame(width: 24, height: 24) + CoreAssets.lockIcon.swiftUIImage + .foregroundStyle(Theme.Colors.textPrimary) + } + .padding(8) + } + } + .background(Theme.Colors.background) + .cornerRadius(8) + .shadow(color: Theme.Colors.courseCardShadow, radius: 6, x: 2, y: 2) + } + + private var courseBanner: some View { + return KFImage(URL(string: courseImage)) + .onFailureImage(CoreAssets.noCourseImage.image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(minWidth: 120, minHeight: 90, maxHeight: 100) + .clipped() + .accessibilityElement(children: .ignore) + .accessibilityIdentifier("course_image") + } + + private var courseTitle: some View { + VStack(alignment: .leading, spacing: 3) { + if let courseEndDate { + Text(courseEndDate.dateToString(style: .courseEndsMonthDDYear)) + .font(Theme.Fonts.labelSmall) + .foregroundStyle(Theme.Colors.textSecondaryLight) + .multilineTextAlignment(.leading) + } else if let courseStartDate { + Text(courseStartDate.dateToString(style: .courseStartsMonthDDYear)) + .font(Theme.Fonts.labelSmall) + .foregroundStyle(Theme.Colors.textSecondaryLight) + .multilineTextAlignment(.leading) + } + Text(courseName) + .font(Theme.Fonts.labelMedium) + .foregroundStyle(Theme.Colors.textPrimary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + .frame(height: showProgress ? 51 : 40, alignment: .topLeading) + .fixedSize(horizontal: false, vertical: true) + .padding(.top, 10) + .padding(.horizontal, 12) + .padding(.bottom, 16) + } +} + +#if DEBUG +#Preview { + CourseCardView( + courseName: "Six Sigma Part 2: Analyze, Improve, Control", + courseImage: "https://thumbs.dreamstime.com/b/logo-edx-samsung-tablet-edx-massive-open-online-course-mooc-provider-hosts-online-university-level-courses-wide-117763805.jpg", + progressEarned: 4, + progressPossible: 8, + courseStartDate: nil, + courseEndDate: Date(), + hasAccess: true, + showProgress: true + ).frame(width: 170) +} +#endif diff --git a/Dashboard/Dashboard/Presentation/Elements/DropDownMenu.swift b/Dashboard/Dashboard/Presentation/Elements/DropDownMenu.swift new file mode 100644 index 000000000..85ba10548 --- /dev/null +++ b/Dashboard/Dashboard/Presentation/Elements/DropDownMenu.swift @@ -0,0 +1,91 @@ +// +// DropDownMenu.swift +// Dashboard +// +// Created by  Stepanok Ivan on 24.04.2024. +// + +import Theme +import Core +import SwiftUI + +enum MenuOption: String, CaseIterable { + case courses + case programs + + var text: String { + switch self { + case .courses: + DashboardLocalization.Learn.DropdownMenu.courses + case .programs: + DashboardLocalization.Learn.DropdownMenu.programs + } + } +} + +struct DropDownMenu: View { + @Binding var selectedOption: MenuOption + @State private var expanded: Bool = false + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(selectedOption.text) + .font(Theme.Fonts.titleSmall) + .accessibilityIdentifier("dropdown_menu_text") + Image(systemName: "chevron.down") + .rotation3DEffect( + .degrees(expanded ? 180 : 0), + axis: (x: 1.0, y: 0.0, z: 0.0) + ) + } + .foregroundColor(Theme.Colors.textPrimary) + .onTapGesture { + withAnimation(.snappy(duration: 0.2)) { + expanded.toggle() + } + } + + if expanded { + VStack(spacing: 0) { + ForEach(Array(MenuOption.allCases.enumerated()), id: \.offset) { index, option in + Button( + action: { + selectedOption = option + expanded = false + }, label: { + HStack { + Text(option.text) + .font(Theme.Fonts.titleSmall) + .foregroundColor( + option == selectedOption ? Theme.Colors.white : Theme.Colors.textPrimary + ) + Spacer() + } + .padding(10) + .background { + ZStack { + RoundedCorners(bl: index == MenuOption.allCases.count-1 ? 8 : 0, + br: index == MenuOption.allCases.count-1 ? 8 : 0) + .foregroundStyle(option == selectedOption + ? Theme.Colors.accentColor + : Theme.Colors.cardViewBackground) + RoundedCorners(bl: index == MenuOption.allCases.count-1 ? 8 : 0, + br: index == MenuOption.allCases.count-1 ? 8 : 0) + .stroke(Theme.Colors.cardViewStroke, style: .init(lineWidth: 1)) + } + } + } + ) + } + } + .frame(minWidth: 182) + .fixedSize() + } + } + .onTapBackground(enabled: expanded, { expanded = false }) + .onDisappear { + expanded = false + } + } +} diff --git a/Dashboard/Dashboard/Presentation/Elements/NoCoursesView.swift b/Dashboard/Dashboard/Presentation/Elements/NoCoursesView.swift new file mode 100644 index 000000000..82a471509 --- /dev/null +++ b/Dashboard/Dashboard/Presentation/Elements/NoCoursesView.swift @@ -0,0 +1,89 @@ +// +// NoCoursesView.swift +// Dashboard +// +// Created by  Stepanok Ivan on 02.05.2024. +// + +import SwiftUI +import Core +import Theme + +struct NoCoursesView: View { + + enum NoCoursesType { + case primary + case inProgress + case completed + case expired + + var title: String { + switch self { + case .primary: + DashboardLocalization.Learn.NoCoursesView.noCourses + case .inProgress: + DashboardLocalization.Learn.NoCoursesView.noCoursesInProgress + case .completed: + DashboardLocalization.Learn.NoCoursesView.noCompletedCourses + case .expired: + DashboardLocalization.Learn.NoCoursesView.noExpiredCourses + } + } + + var description: String? { + switch self { + case .primary: + DashboardLocalization.Learn.NoCoursesView.noCoursesDescription + case .inProgress, .completed, .expired: + nil + } + } + } + + private let type: NoCoursesType + private var openDiscovery: (() -> Void) + + init(openDiscovery: @escaping (() -> Void)) { + self.type = .primary + self.openDiscovery = openDiscovery + } + + init(selectedMenu: CategoryOption) { + switch selectedMenu { + case .all: + type = .inProgress + case .inProgress: + type = .inProgress + case .completed: + type = .completed + case .expired: + type = .expired + } + openDiscovery = {} + } + + var body: some View { + VStack(spacing: 8) { + Spacer() + CoreAssets.learnEmpty.swiftUIImage + .resizable() + .frame(width: 96, height: 96) + .foregroundStyle(Theme.Colors.textSecondaryLight) + Text(type.title) + .foregroundStyle(Theme.Colors.textPrimary) + .font(Theme.Fonts.titleMedium) + if let description = type.description { + Text(description) + .multilineTextAlignment(.center) + .foregroundStyle(Theme.Colors.textPrimary) + .font(Theme.Fonts.labelMedium) + .frame(width: 245) + } + Spacer() + if type == .primary { + StyledButton(DashboardLocalization.Learn.NoCoursesView.findACourse, action: { openDiscovery() }) + .padding(24) + } + } + } +} diff --git a/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift b/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift new file mode 100644 index 000000000..8428c1857 --- /dev/null +++ b/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift @@ -0,0 +1,276 @@ +// +// PrimaryCardView.swift +// Dashboard +// +// Created by  Stepanok Ivan on 16.04.2024. +// + +import SwiftUI +import Kingfisher +import Theme +import Core + +public struct PrimaryCardView: View { + + private let courseName: String + private let org: String + private let courseImage: String + private let courseStartDate: Date? + private let courseEndDate: Date? + private var futureAssignments: [Assignment] + private let pastAssignments: [Assignment] + private let progressEarned: Int + private let progressPossible: Int + private let canResume: Bool + private let resumeTitle: String? + private var assignmentAction: (String?) -> Void + private var openCourseAction: () -> Void + private var resumeAction: () -> Void + + public init( + courseName: String, + org: String, + courseImage: String, + courseStartDate: Date?, + courseEndDate: Date?, + futureAssignments: [Assignment], + pastAssignments: [Assignment], + progressEarned: Int, + progressPossible: Int, + canResume: Bool, + resumeTitle: String?, + assignmentAction: @escaping (String?) -> Void, + openCourseAction: @escaping () -> Void, + resumeAction: @escaping () -> Void + ) { + self.courseName = courseName + self.org = org + self.courseImage = courseImage + self.courseStartDate = courseStartDate + self.courseEndDate = courseEndDate + self.futureAssignments = futureAssignments + self.pastAssignments = pastAssignments + self.progressEarned = progressEarned + self.progressPossible = progressPossible + self.canResume = canResume + self.resumeTitle = resumeTitle + self.assignmentAction = assignmentAction + self.openCourseAction = openCourseAction + self.resumeAction = resumeAction + } + + public var body: some View { + ZStack { + VStack(alignment: .leading, spacing: 0) { + Group { + courseBanner + ProgressLineView(progressEarned: progressEarned, progressPossible: progressPossible) + courseTitle + } + .onTapGesture { + openCourseAction() + } + assignments + } + } + .background(Theme.Colors.background) + .cornerRadius(8) + .shadow(color: Theme.Colors.courseCardShadow, radius: 4, x: 0, y: 3) + .padding(20) + } + + private var assignments: some View { + VStack(alignment: .leading, spacing: 8) { + // pastAssignments + if pastAssignments.count == 1, let pastAssignment = pastAssignments.first { + courseButton( + title: pastAssignment.title, + description: DashboardLocalization.Learn.PrimaryCard.onePastAssignment, + icon: CoreAssets.warning.swiftUIImage, + selected: false, + action: { assignmentAction(pastAssignments.first?.firstComponentBlockId) } + ) + } else if pastAssignments.count > 1 { + courseButton( + title: DashboardLocalization.Learn.PrimaryCard.viewAssignments, + description: DashboardLocalization.Learn.PrimaryCard.pastAssignments(pastAssignments.count), + icon: CoreAssets.warning.swiftUIImage, + selected: false, + action: { assignmentAction(nil) } + ) + } + + // futureAssignment + if !futureAssignments.isEmpty { + if futureAssignments.count == 1, let futureAssignment = futureAssignments.first { + let daysRemaining = Calendar.current.dateComponents( + [.day], + from: Date(), + to: futureAssignment.date + ).day ?? 0 + courseButton( + title: futureAssignment.title, + description: DashboardLocalization.Learn.PrimaryCard.dueDays( + futureAssignment.type, + daysRemaining + ), + icon: CoreAssets.chapter.swiftUIImage, + selected: false, + action: { + assignmentAction(futureAssignments.first?.firstComponentBlockId) + } + ) + } else if futureAssignments.count > 1 { + if let firtsData = futureAssignments.sorted(by: { $0.date < $1.date }).first { + courseButton( + title: DashboardLocalization.Learn.PrimaryCard.futureAssignments( + futureAssignments.count, + firtsData.date.dateToString(style: .lastPost) + ), + description: nil, + icon: CoreAssets.chapter.swiftUIImage, + selected: false, + action: { + assignmentAction(nil) + } + ) + } + } + } + + // ResumeButton + if canResume { + courseButton( + title: resumeTitle ?? "", + description: DashboardLocalization.Learn.PrimaryCard.resume, + icon: CoreAssets.resumeCourse.swiftUIImage, + selected: true, + action: { resumeAction() } + ) + } else { + courseButton( + title: DashboardLocalization.Learn.PrimaryCard.startCourse, + description: nil, + icon: CoreAssets.resumeCourse.swiftUIImage, + selected: true, + action: { resumeAction() } + ) + } + } + } + + private func courseButton( + title: String, + description: String?, + icon: Image, + selected: Bool, + action: @escaping () -> Void + ) -> some View { + Button(action: { + action() + }, label: { + ZStack(alignment: .top) { + Rectangle().frame(height: selected ? 0 : 1) + .foregroundStyle(Theme.Colors.cardViewStroke) + HStack(alignment: .center) { + VStack(alignment: .leading) { + HStack(spacing: 0) { + icon + .renderingMode(.template) + .resizable() + .frame(width: 24, height: 24) + .foregroundStyle( + selected ? Theme.Colors.white : Theme.Colors.textPrimary + ) + .padding(12) + + VStack(alignment: .leading, spacing: 6) { + if let description { + Text(description) + .font(Theme.Fonts.labelSmall) + .multilineTextAlignment(.leading) + .lineLimit(1) + .foregroundStyle(selected ? Theme.Colors.white : Theme.Colors.textPrimary) + } + Text(title) + .font(Theme.Fonts.titleSmall) + .multilineTextAlignment(.leading) + .lineLimit(1) + .foregroundStyle(selected ? Theme.Colors.white : Theme.Colors.textPrimary) + } + .padding(.top, 2) + } + } + Spacer() + CoreAssets.chevronRight.swiftUIImage + .foregroundStyle(selected ? Theme.Colors.white : Theme.Colors.textPrimary) + .padding(8) + } + .padding(.top, 8) + .padding(.bottom, selected ? 10 : 0) + }.background(selected ? Theme.Colors.accentColor : .clear) + }) + } + + private var courseBanner: some View { + return KFImage(URL(string: courseImage)) + .onFailureImage(CoreAssets.noCourseImage.image) + .resizable() + .aspectRatio(contentMode: .fill) + .frame(height: 140) + .clipped() + .accessibilityElement(children: .ignore) + .accessibilityIdentifier("course_image") + } + + private var courseTitle: some View { + VStack(alignment: .leading, spacing: 3) { + Text(org) + .font(Theme.Fonts.labelMedium) + .foregroundStyle(Theme.Colors.textSecondaryLight) + Text(courseName) + .font(Theme.Fonts.titleLarge) + .foregroundStyle(Theme.Colors.textPrimary) + .lineLimit(3) + if let courseEndDate { + Text(courseEndDate.dateToString(style: .courseEndsMonthDDYear)) + .font(Theme.Fonts.labelMedium) + .foregroundStyle(Theme.Colors.textSecondaryLight) + } else if let courseStartDate { + Text(courseStartDate.dateToString(style: .courseStartsMonthDDYear)) + .font(Theme.Fonts.labelMedium) + .foregroundStyle(Theme.Colors.textSecondaryLight) + } + } + .padding(.top, 10) + .padding(.horizontal, 12) + .padding(.bottom, 16) + } +} + +#if DEBUG +struct PrimaryCardView_Previews: PreviewProvider { + static var previews: some View { + ZStack { + Theme.Colors.background + PrimaryCardView( + courseName: "Course Title", + org: "Organization", + courseImage: "https://thumbs.dreamstime.com/b/logo-edx-samsung-tablet-edx-massive-open-online-course-mooc-provider-hosts-online-university-level-courses-wide-117763805.jpg", + courseStartDate: nil, + courseEndDate: Date(), + futureAssignments: [], + pastAssignments: [], + progressEarned: 10, + progressPossible: 45, + canResume: true, + resumeTitle: "Course Chapter 1", + assignmentAction: {_ in }, + openCourseAction: {}, + resumeAction: {} + ) + .loadFonts() + } + } +} +#endif diff --git a/Dashboard/Dashboard/Presentation/Elements/ProgressLineView.swift b/Dashboard/Dashboard/Presentation/Elements/ProgressLineView.swift new file mode 100644 index 000000000..611ddbcb1 --- /dev/null +++ b/Dashboard/Dashboard/Presentation/Elements/ProgressLineView.swift @@ -0,0 +1,48 @@ +// +// ProgressLineView.swift +// Dashboard +// +// Created by  Stepanok Ivan on 16.04.2024. +// + +import SwiftUI +import Theme + +struct ProgressLineView: View { + private let progressEarned: Int + private let progressPossible: Int + private let height: CGFloat + + var progressValue: CGFloat { + guard progressPossible != 0 else { return 0 } + return CGFloat(progressEarned) / CGFloat(progressPossible) + } + + init(progressEarned: Int, progressPossible: Int, height: CGFloat = 8) { + self.progressEarned = progressEarned + self.progressPossible = progressPossible + self.height = height + } + + var body: some View { + ZStack(alignment: .leading) { + GeometryReader { geometry in + Rectangle() + .foregroundStyle(Theme.Colors.cardViewStroke) + Rectangle() + .foregroundStyle(Theme.Colors.accentColor) + .frame(width: geometry.size.width * progressValue) + }.frame(height: height) + } + } +} + +#if DEBUG +struct ProgressLineView_Previews: PreviewProvider { + static var previews: some View { + ProgressLineView(progressEarned: 4, progressPossible: 6) + .frame(height: 8) + .padding() + } +} +#endif diff --git a/Dashboard/Dashboard/Presentation/DashboardView.swift b/Dashboard/Dashboard/Presentation/ListDashboardView.swift similarity index 90% rename from Dashboard/Dashboard/Presentation/DashboardView.swift rename to Dashboard/Dashboard/Presentation/ListDashboardView.swift index 44c6c3fc8..d3787ebad 100644 --- a/Dashboard/Dashboard/Presentation/DashboardView.swift +++ b/Dashboard/Dashboard/Presentation/ListDashboardView.swift @@ -1,5 +1,5 @@ // -// DashboardView.swift +// ListDashboardView.swift // Dashboard // // Created by  Stepanok Ivan on 19.09.2022. @@ -9,7 +9,7 @@ import SwiftUI import Core import Theme -public struct DashboardView: View { +public struct ListDashboardView: View { private let dashboardCourses: some View = VStack(alignment: .leading) { Text(DashboardLocalization.Header.courses) .font(Theme.Fonts.displaySmall) @@ -25,10 +25,10 @@ public struct DashboardView: View { .accessibilityLabel(DashboardLocalization.Header.courses + DashboardLocalization.Header.welcomeBack) @StateObject - private var viewModel: DashboardViewModel + private var viewModel: ListDashboardViewModel private let router: DashboardRouter - public init(viewModel: DashboardViewModel, router: DashboardRouter) { + public init(viewModel: ListDashboardViewModel, router: DashboardRouter) { self._viewModel = StateObject(wrappedValue: { viewModel }()) self.router = router } @@ -76,12 +76,14 @@ public struct DashboardView: View { ) router.showCourseScreens( courseID: course.courseID, - isActive: course.isActive, + hasAccess: course.hasAccess, courseStart: course.courseStart, courseEnd: course.courseEnd, enrollmentStart: course.enrollmentStart, enrollmentEnd: course.enrollmentEnd, - title: course.name + title: course.name, + showDates: false, + lastVisitedBlockID: nil ) } .accessibilityIdentifier("course_item") @@ -138,22 +140,22 @@ public struct DashboardView: View { } #if DEBUG -struct DashboardView_Previews: PreviewProvider { +struct ListDashboardView_Previews: PreviewProvider { static var previews: some View { - let vm = DashboardViewModel( + let vm = ListDashboardViewModel( interactor: DashboardInteractor.mock, connectivity: Connectivity(), analytics: DashboardAnalyticsMock() ) let router = DashboardRouterMock() - DashboardView(viewModel: vm, router: router) + ListDashboardView(viewModel: vm, router: router) .preferredColorScheme(.light) - .previewDisplayName("DashboardView Light") + .previewDisplayName("ListDashboardView Light") - DashboardView(viewModel: vm, router: router) + ListDashboardView(viewModel: vm, router: router) .preferredColorScheme(.dark) - .previewDisplayName("DashboardView Dark") + .previewDisplayName("ListDashboardView Dark") } } #endif diff --git a/Dashboard/Dashboard/Presentation/DashboardViewModel.swift b/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift similarity index 90% rename from Dashboard/Dashboard/Presentation/DashboardViewModel.swift rename to Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift index 6e4d9974a..4e79877c2 100644 --- a/Dashboard/Dashboard/Presentation/DashboardViewModel.swift +++ b/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift @@ -1,5 +1,5 @@ // -// DashboardViewModel.swift +// ListDashboardViewModel.swift // Dashboard // // Created by  Stepanok Ivan on 19.09.2022. @@ -10,7 +10,7 @@ import Core import SwiftUI import Combine -public class DashboardViewModel: ObservableObject { +public class ListDashboardViewModel: ObservableObject { public var nextPage = 1 public var totalPages = 1 @@ -54,11 +54,11 @@ public class DashboardViewModel: ObservableObject { fetchInProgress = true if connectivity.isInternetAvaliable { if refresh { - courses = try await interactor.getMyCourses(page: page) + courses = try await interactor.getEnrollments(page: page) self.totalPages = 1 self.nextPage = 2 } else { - courses += try await interactor.getMyCourses(page: page) + courses += try await interactor.getEnrollments(page: page) self.nextPage += 1 } if !courses.isEmpty { @@ -66,7 +66,7 @@ public class DashboardViewModel: ObservableObject { } fetchInProgress = false } else { - courses = try interactor.discoveryOffline() + courses = try interactor.getEnrollmentsOffline() self.nextPage += 1 fetchInProgress = false } diff --git a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift new file mode 100644 index 000000000..7ac041110 --- /dev/null +++ b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift @@ -0,0 +1,357 @@ +// +// PrimaryCourseDashboardView.swift +// Dashboard +// +// Created by  Stepanok Ivan on 16.04.2024. +// + +import SwiftUI +import Core +import Theme +import Swinject + +public struct PrimaryCourseDashboardView: View { + + @StateObject private var viewModel: PrimaryCourseDashboardViewModel + private let router: DashboardRouter + @ViewBuilder let programView: ProgramView + private var openDiscoveryPage: () -> Void + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + + @State private var selectedMenu: MenuOption = .courses + + public init( + viewModel: PrimaryCourseDashboardViewModel, + router: DashboardRouter, + programView: ProgramView, + openDiscoveryPage: @escaping () -> Void + ) { + self._viewModel = StateObject(wrappedValue: { viewModel }()) + self.router = router + self.programView = programView + self.openDiscoveryPage = openDiscoveryPage + } + + public var body: some View { + GeometryReader { proxy in + ZStack(alignment: .top) { + if viewModel.enrollments?.primaryCourse == nil + && !viewModel.fetchInProgress + && selectedMenu == .courses { + NoCoursesView(openDiscovery: { + openDiscoveryPage() + }).zIndex(1) + } + learnTitleAndSearch(proxy: proxy) + .zIndex(1) + // MARK: - Page body + VStack(alignment: .leading) { + Spacer(minLength: 50) + switch selectedMenu { + case .courses: + RefreshableScrollViewCompat(action: { + await viewModel.getEnrollments(showProgress: false) + }) { + ZStack(alignment: .topLeading) { + if viewModel.fetchInProgress { + VStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 200) + }.frame(maxWidth: .infinity, + maxHeight: .infinity) + } else { + LazyVStack(spacing: 0) { + if let enrollments = viewModel.enrollments { + if let primary = enrollments.primaryCourse { + PrimaryCardView( + courseName: primary.name, + org: primary.org, + courseImage: primary.courseBanner, + courseStartDate: primary.courseStart, + courseEndDate: primary.courseEnd, + futureAssignments: primary.futureAssignments, + pastAssignments: primary.pastAssignments, + progressEarned: primary.progressEarned, + progressPossible: primary.progressPossible, + canResume: primary.lastVisitedBlockID != nil, + resumeTitle: primary.resumeTitle, + assignmentAction: { lastVisitedBlockID in + router.showCourseScreens( + courseID: primary.courseID, + hasAccess: primary.hasAccess, + courseStart: primary.courseStart, + courseEnd: primary.courseEnd, + enrollmentStart: nil, + enrollmentEnd: nil, + title: primary.name, + showDates: lastVisitedBlockID == nil, + lastVisitedBlockID: lastVisitedBlockID + ) + }, + openCourseAction: { + router.showCourseScreens( + courseID: primary.courseID, + hasAccess: primary.hasAccess, + courseStart: primary.courseStart, + courseEnd: primary.courseEnd, + enrollmentStart: nil, + enrollmentEnd: nil, + title: primary.name, + showDates: false, + lastVisitedBlockID: nil + ) + }, + resumeAction: { + router.showCourseScreens( + courseID: primary.courseID, + hasAccess: primary.hasAccess, + courseStart: primary.courseStart, + courseEnd: primary.courseEnd, + enrollmentStart: nil, + enrollmentEnd: nil, + title: primary.name, + showDates: false, + lastVisitedBlockID: primary.lastVisitedBlockID + ) + } + ) + } + if !enrollments.courses.isEmpty { + viewAll(enrollments) + } + if idiom == .pad { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16) + ], + alignment: .leading, + spacing: 15 + ) { + courses(enrollments) + } + .padding(20) + } else { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 16) { + courses(enrollments) + } + .padding(20) + } + } + Spacer(minLength: 100) + } + } + } + } + .frameLimit(width: proxy.size.width) + }.accessibilityAction {} + case .programs: + programView + } + }.padding(.top, 8) + // MARK: - Offline mode SnackBar + OfflineSnackBarView( + connectivity: viewModel.connectivity, + reloadAction: { + await viewModel.getEnrollments(showProgress: false) + } + ).zIndex(2) + + // 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 + } + } + .zIndex(2) + } + } + .onFirstAppear { + Task { + await viewModel.getEnrollments() + } + } + .onAppear { + viewModel.updateNeeded = true + } + .background( + Theme.Colors.background + .ignoresSafeArea() + ) + .navigationBarBackButtonHidden(true) + .navigationBarHidden(true) + .navigationTitle(DashboardLocalization.title) + } + } + + @ViewBuilder + private func courses(_ enrollments: PrimaryEnrollment) -> some View { + ForEach( + Array(enrollments.courses.enumerated()), + id: \.offset + ) { _, course in + Button(action: { + viewModel.trackDashboardCourseClicked( + courseID: course.courseID, + courseName: course.name + ) + router.showCourseScreens( + courseID: course.courseID, + hasAccess: course.hasAccess, + courseStart: course.courseStart, + courseEnd: course.courseEnd, + enrollmentStart: course.enrollmentStart, + enrollmentEnd: course.enrollmentEnd, + title: course.name, + showDates: false, + lastVisitedBlockID: nil + ) + }, label: { + CourseCardView( + courseName: course.name, + courseImage: course.imageURL, + progressEarned: 0, + progressPossible: 0, + courseStartDate: nil, + courseEndDate: nil, + hasAccess: course.hasAccess, + showProgress: false + ).frame(width: idiom == .pad ? nil : 120) + } + ) + .accessibilityIdentifier("course_item") + } + if enrollments.courses.count < enrollments.count { + viewAllButton(enrollments) + } + } + + private func viewAllButton(_ enrollments: PrimaryEnrollment) -> some View { + Button(action: { + router.showAllCourses(courses: enrollments.courses) + }, label: { + ZStack(alignment: .topTrailing) { + HStack { + Spacer() + VStack(alignment: .leading, spacing: 0) { + Spacer() + CoreAssets.viewAll.swiftUIImage + Text(DashboardLocalization.Learn.viewAll) + .font(Theme.Fonts.labelMedium) + .foregroundStyle(Theme.Colors.textPrimary) + Spacer() + } + Spacer() + } + .frame(width: idiom == .pad ? nil : 120) + } + .background(Theme.Colors.cardViewBackground) + .cornerRadius(8) + .shadow(color: Theme.Colors.courseCardShadow, radius: 6, x: 2, y: 2) + }) + } + + private func viewAll(_ enrollments: PrimaryEnrollment) -> some View { + Button(action: { + router.showAllCourses(courses: enrollments.courses) + }, label: { + HStack { + Text(DashboardLocalization.Learn.viewAllCourses(enrollments.count + 1)) + .font(Theme.Fonts.titleSmall) + .accessibilityIdentifier("courses_welcomeback_text") + Image(systemName: "chevron.right") + } + .padding(.horizontal, 16) + .foregroundColor(Theme.Colors.textPrimary) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + }) + } + + private func learnTitleAndSearch(proxy: GeometryProxy) -> some View { + let showDropdown = viewModel.config.program.enabled && viewModel.config.program.isWebViewConfigured + return ZStack(alignment: .top) { + Theme.Colors.background + .frame(height: showDropdown ? 70 : 50) + ZStack(alignment: .topTrailing) { + VStack { + HStack(alignment: .center) { + Text(DashboardLocalization.Learn.title) + .font(Theme.Fonts.displaySmall) + .foregroundColor(Theme.Colors.textPrimary) + .accessibilityIdentifier("courses_header_text") + Spacer() + } + if showDropdown { + HStack(alignment: .center) { + DropDownMenu(selectedOption: $selectedMenu) + Spacer() + } + } + } + .frameLimit(width: proxy.size.width) + HStack { + Spacer() + Button(action: { + router.showSettings() + }, label: { + CoreAssets.settings.swiftUIImage.renderingMode(.template) + .foregroundColor(Theme.Colors.accentColor) + }) + } + .padding(.top, 8) + .offset(x: idiom == .pad ? 1 : 5, y: idiom == .pad ? 4 : -5) + } + + .listRowBackground(Color.clear) + .padding(.horizontal, 20) + .accessibilityElement(children: .ignore) + .accessibilityLabel(DashboardLocalization.Header.courses + DashboardLocalization.Header.welcomeBack) + } + } +} + +#if DEBUG +struct PrimaryCourseDashboardView_Previews: PreviewProvider { + static var previews: some View { + let vm = PrimaryCourseDashboardViewModel( + interactor: DashboardInteractor.mock, + connectivity: Connectivity(), + analytics: DashboardAnalyticsMock(), + config: ConfigMock() + ) + + PrimaryCourseDashboardView( + viewModel: vm, + router: DashboardRouterMock(), + programView: EmptyView(), + openDiscoveryPage: { + } + ) + .preferredColorScheme(.light) + .previewDisplayName("DashboardView Light") + + PrimaryCourseDashboardView( + viewModel: vm, + router: DashboardRouterMock(), + programView: EmptyView(), + openDiscoveryPage: { + } + ) + .preferredColorScheme(.dark) + .previewDisplayName("DashboardView Dark") + } +} +#endif diff --git a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift new file mode 100644 index 000000000..7b3a51e37 --- /dev/null +++ b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift @@ -0,0 +1,102 @@ +// +// PrimaryCourseDashboardViewModel.swift +// Dashboard +// +// Created by  Stepanok Ivan on 16.04.2024. +// + +import Foundation +import Core +import SwiftUI +import Combine + +public class PrimaryCourseDashboardViewModel: ObservableObject { + + var nextPage = 1 + var totalPages = 1 + @Published public private(set) var fetchInProgress = true + @Published var enrollments: PrimaryEnrollment? + @Published var showError: Bool = false + @Published var updateNeeded: Bool = false + var errorMessage: String? { + didSet { + withAnimation { + showError = errorMessage != nil + } + } + } + + let connectivity: ConnectivityProtocol + private let interactor: DashboardInteractorProtocol + private let analytics: DashboardAnalytics + let config: ConfigProtocol + private var cancellables = Set() + + private let ipadPageSize = 7 + private let iphonePageSize = 5 + + public init( + interactor: DashboardInteractorProtocol, + connectivity: ConnectivityProtocol, + analytics: DashboardAnalytics, + config: ConfigProtocol + ) { + self.interactor = interactor + self.connectivity = connectivity + self.analytics = analytics + self.config = config + + let enrollmentPublisher = NotificationCenter.default.publisher(for: .onCourseEnrolled) + let completionPublisher = NotificationCenter.default.publisher(for: .onblockCompletionRequested) + + enrollmentPublisher + .sink { [weak self] _ in + guard let self = self else { return } + Task { + await self.getEnrollments() + } + } + .store(in: &cancellables) + + completionPublisher + .sink { [weak self] _ in + guard let self = self else { return } + updateEnrollmentsIfNeeded() + } + .store(in: &cancellables) + } + + private func updateEnrollmentsIfNeeded() { + guard updateNeeded else { return } + Task { + await getEnrollments() + updateNeeded = false + } + } + + @MainActor + public func getEnrollments(showProgress: Bool = true) async { + let pageSize = UIDevice.current.userInterfaceIdiom == .pad ? ipadPageSize : iphonePageSize + fetchInProgress = showProgress + do { + if connectivity.isInternetAvaliable { + enrollments = try await interactor.getPrimaryEnrollment(pageSize: pageSize) + fetchInProgress = false + } else { + enrollments = try await interactor.getPrimaryEnrollmentOffline() + fetchInProgress = false + } + } catch let error { + fetchInProgress = false + if error is NoCachedDataError { + errorMessage = CoreLocalization.Error.noCachedData + } else { + errorMessage = CoreLocalization.Error.unknownError + } + } + } + + func trackDashboardCourseClicked(courseID: String, courseName: String) { + analytics.dashboardCourseClicked(courseID: courseID, courseName: courseName) + } +} diff --git a/Dashboard/Dashboard/SwiftGen/Strings.swift b/Dashboard/Dashboard/SwiftGen/Strings.swift index aac74931c..7b5924613 100644 --- a/Dashboard/Dashboard/SwiftGen/Strings.swift +++ b/Dashboard/Dashboard/SwiftGen/Strings.swift @@ -10,6 +10,8 @@ import Foundation // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces public enum DashboardLocalization { + /// Search + public static let search = DashboardLocalization.tr("Localizable", "SEARCH", fallback: "Search") /// Localizable.strings /// Dashboard /// @@ -25,6 +27,70 @@ public enum DashboardLocalization { /// Welcome back. Let's keep learning. public static let welcomeBack = DashboardLocalization.tr("Localizable", "HEADER.WELCOME_BACK", fallback: "Welcome back. Let's keep learning.") } + public enum Learn { + /// All Courses + public static let allCourses = DashboardLocalization.tr("Localizable", "LEARN.ALL_COURSES", fallback: "All Courses") + /// Learn + public static let title = DashboardLocalization.tr("Localizable", "LEARN.TITLE", fallback: "Learn") + /// View All + public static let viewAll = DashboardLocalization.tr("Localizable", "LEARN.VIEW_ALL", fallback: "View All") + /// View All Courses (%@) + public static func viewAllCourses(_ p1: Any) -> String { + return DashboardLocalization.tr("Localizable", "LEARN.VIEW_ALL_COURSES", String(describing: p1), fallback: "View All Courses (%@)") + } + public enum Category { + /// All + public static let all = DashboardLocalization.tr("Localizable", "LEARN.CATEGORY.ALL", fallback: "All") + /// Completed + public static let completed = DashboardLocalization.tr("Localizable", "LEARN.CATEGORY.COMPLETED", fallback: "Completed") + /// Expired + public static let expired = DashboardLocalization.tr("Localizable", "LEARN.CATEGORY.EXPIRED", fallback: "Expired") + /// In Progress + public static let inProgress = DashboardLocalization.tr("Localizable", "LEARN.CATEGORY.IN_PROGRESS", fallback: "In Progress") + } + public enum DropdownMenu { + /// Courses + public static let courses = DashboardLocalization.tr("Localizable", "LEARN.DROPDOWN_MENU.COURSES", fallback: "Courses") + /// Programs + public static let programs = DashboardLocalization.tr("Localizable", "LEARN.DROPDOWN_MENU.PROGRAMS", fallback: "Programs") + } + public enum NoCoursesView { + /// Find a Course + public static let findACourse = DashboardLocalization.tr("Localizable", "LEARN.NO_COURSES_VIEW.FIND_A_COURSE", fallback: "Find a Course") + /// No Completed Courses + public static let noCompletedCourses = DashboardLocalization.tr("Localizable", "LEARN.NO_COURSES_VIEW.NO_COMPLETED_COURSES", fallback: "No Completed Courses") + /// No Courses + public static let noCourses = DashboardLocalization.tr("Localizable", "LEARN.NO_COURSES_VIEW.NO_COURSES", fallback: "No Courses") + /// You are not currently enrolled in any courses, would you like to explore the course catalog? + public static let noCoursesDescription = DashboardLocalization.tr("Localizable", "LEARN.NO_COURSES_VIEW.NO_COURSES_DESCRIPTION", fallback: "You are not currently enrolled in any courses, would you like to explore the course catalog?") + /// No Courses in Progress + public static let noCoursesInProgress = DashboardLocalization.tr("Localizable", "LEARN.NO_COURSES_VIEW.NO_COURSES_IN_PROGRESS", fallback: "No Courses in Progress") + /// No Expired Courses + public static let noExpiredCourses = DashboardLocalization.tr("Localizable", "LEARN.NO_COURSES_VIEW.NO_EXPIRED_COURSES", fallback: "No Expired Courses") + } + public enum PrimaryCard { + /// %@ Due in %@ Days + public static func dueDays(_ p1: Any, _ p2: Any) -> String { + return DashboardLocalization.tr("Localizable", "LEARN.PRIMARY_CARD.DUE_DAYS", String(describing: p1), String(describing: p2), fallback: "%@ Due in %@ Days") + } + /// %@ Assignments Due %@ + public static func futureAssignments(_ p1: Any, _ p2: Any) -> String { + return DashboardLocalization.tr("Localizable", "LEARN.PRIMARY_CARD.FUTURE_ASSIGNMENTS", String(describing: p1), String(describing: p2), fallback: "%@ Assignments Due %@ ") + } + /// 1 Past Due Assignment + public static let onePastAssignment = DashboardLocalization.tr("Localizable", "LEARN.PRIMARY_CARD.ONE_PAST_ASSIGNMENT", fallback: "1 Past Due Assignment") + /// %@ Past Due Assignments + public static func pastAssignments(_ p1: Any) -> String { + return DashboardLocalization.tr("Localizable", "LEARN.PRIMARY_CARD.PAST_ASSIGNMENTS", String(describing: p1), fallback: "%@ Past Due Assignments") + } + /// Resume Course + public static let resume = DashboardLocalization.tr("Localizable", "LEARN.PRIMARY_CARD.RESUME", fallback: "Resume Course") + /// Start Course + public static let startCourse = DashboardLocalization.tr("Localizable", "LEARN.PRIMARY_CARD.START_COURSE", fallback: "Start Course") + /// View Assignments + public static let viewAssignments = DashboardLocalization.tr("Localizable", "LEARN.PRIMARY_CARD.VIEW_ASSIGNMENTS", fallback: "View Assignments") + } + } } // swiftlint:enable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:enable nesting type_body_length type_name vertical_whitespace_opening_braces diff --git a/Dashboard/Dashboard/en.lproj/Localizable.strings b/Dashboard/Dashboard/en.lproj/Localizable.strings index 88fc5d371..406b6c34e 100644 --- a/Dashboard/Dashboard/en.lproj/Localizable.strings +++ b/Dashboard/Dashboard/en.lproj/Localizable.strings @@ -10,4 +10,36 @@ "HEADER.COURSES" = "Courses"; "HEADER.WELCOME_BACK" = "Welcome back. Let's keep learning."; +"SEARCH" = "Search"; + "EMPTY.SUBTITLE" = "You are not enrolled in any courses yet."; + +"LEARN.TITLE" = "Learn"; +"LEARN.VIEW_ALL" = "View All"; +"LEARN.VIEW_ALL_COURSES" = "View All Courses (%@)"; +"LEARN.ALL_COURSES" = "All Courses"; + +"LEARN.PRIMARY_CARD.ONE_PAST_ASSIGNMENT" = "1 Past Due Assignment"; +"LEARN.PRIMARY_CARD.VIEW_ASSIGNMENTS" = "View Assignments"; +"LEARN.PRIMARY_CARD.PAST_ASSIGNMENTS" = "%@ Past Due Assignments"; +"LEARN.PRIMARY_CARD.FUTURE_ASSIGNMENTS" = "%@ Assignments Due %@ "; +"LEARN.PRIMARY_CARD.DUE_DAYS" = "%@ Due in %@ Days"; +"LEARN.PRIMARY_CARD.RESUME" = "Resume Course"; +"LEARN.PRIMARY_CARD.START_COURSE" = "Start Course"; + +"LEARN.DROPDOWN_MENU.COURSES" = "Courses"; +"LEARN.DROPDOWN_MENU.PROGRAMS" = "Programs"; + +"LEARN.CATEGORY.ALL" = "All"; +"LEARN.CATEGORY.IN_PROGRESS" = "In Progress"; +"LEARN.CATEGORY.COMPLETED" = "Completed"; +"LEARN.CATEGORY.EXPIRED" = "Expired"; + +"LEARN.NO_COURSES_VIEW.NO_COURSES" = "No Courses"; +"LEARN.NO_COURSES_VIEW.NO_COURSES_IN_PROGRESS" = "No Courses in Progress"; +"LEARN.NO_COURSES_VIEW.NO_COMPLETED_COURSES" = "No Completed Courses"; +"LEARN.NO_COURSES_VIEW.NO_EXPIRED_COURSES" = "No Expired Courses"; + +"LEARN.NO_COURSES_VIEW.NO_COURSES_DESCRIPTION" = "You are not currently enrolled in any courses, would you like to explore the course catalog?"; + +"LEARN.NO_COURSES_VIEW.FIND_A_COURSE" = "Find a Course"; diff --git a/Dashboard/Dashboard/uk.lproj/Localizable.strings b/Dashboard/Dashboard/uk.lproj/Localizable.strings index 748f2c021..e02337c90 100644 --- a/Dashboard/Dashboard/uk.lproj/Localizable.strings +++ b/Dashboard/Dashboard/uk.lproj/Localizable.strings @@ -10,5 +10,36 @@ "HEADER.COURSES" = "Курси"; "HEADER.WELCOME_BACK" = "З поверненням. Давайте продовжимо вчитись."; +"SEARCH" = "Пошук"; + "EMPTY.TITLE" = "Нічого немає"; "EMPTY.SUBTITLE" = "Ви не підписані на жодний курс."; + +"LEARN.TITLE" = "Навчання"; +"LEARN.VIEW_ALL" = "Переглянути все (%@)"; +"LEARN.ALL_COURSES" = "Усі курси"; + +"LEARN.PRIMARY_CARD.ONE_PAST_ASSIGNMENT" = "1 прострочене завдання"; +"LEARN.PRIMARY_CARD.VIEW_ASSIGNMENTS" = "Переглянути завдання"; +"LEARN.PRIMARY_CARD.PAST_ASSIGNMENTS" = "%@ Прострочені завдання"; +"LEARN.PRIMARY_CARD.FUTURE_ASSIGNMENTS" = "%@ Завданнь %@"; +"LEARN.PRIMARY_CARD.DUE_DAYS" = "%@ Оплата через %@ днів"; +"LEARN.PRIMARY_CARD.RESUME" = "Відновити курс"; +"LEARN.PRIMARY_CARD.START_COURSE" = "Розпочати курс"; + +"LEARN.DROPDOWN_MENU.COURSES" = "Курси"; +"LEARN.DROPDOWN_MENU.PROGRAMS" = "Програми"; + +"LEARN.CATEGORY.ALL" = "Усі"; +"LEARN.CATEGORY.IN_PROGRESS" = "Виконується"; +"LEARN.CATEGORY.COMPLETED" = "Завершено"; +"LEARN.CATEGORY.EXPIRED" = "Закінчився"; + +"LEARN.NO_COURSES_VIEW.NO_COURSES" = "Немає курсів"; +"LEARN.NO_COURSES_VIEW.NO_COURSES_IN_PROGRESS" = "Немає поточних курсів"; +"LEARN.NO_COURSES_VIEW.NO_COMPLETED_COURSES" = "Немає завершених курсів"; +"LEARN.NO_COURSES_VIEW.NO_EXPIRED_COURSES" = "Немає прострочених курсів"; + +"LEARN.NO_COURSES_VIEW.NO_COURSES_DESCRIPTION" = "Наразі ви не зареєстровані на жодному курсі, бажаєте переглянути каталог?"; + +"LEARN.NO_COURSES_VIEW.FIND_A_COURSE" = "Знайти курс"; diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index fb6a1334e..27620fef4 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -1609,32 +1609,80 @@ open class DashboardInteractorProtocolMock: DashboardInteractorProtocol, Mock { - open func getMyCourses(page: Int) throws -> [CourseItem] { - addInvocation(.m_getMyCourses__page_page(Parameter.value(`page`))) - let perform = methodPerformValue(.m_getMyCourses__page_page(Parameter.value(`page`))) as? (Int) -> Void + open func getEnrollments(page: Int) throws -> [CourseItem] { + addInvocation(.m_getEnrollments__page_page(Parameter.value(`page`))) + let perform = methodPerformValue(.m_getEnrollments__page_page(Parameter.value(`page`))) as? (Int) -> Void perform?(`page`) var __value: [CourseItem] do { - __value = try methodReturnValue(.m_getMyCourses__page_page(Parameter.value(`page`))).casted() + __value = try methodReturnValue(.m_getEnrollments__page_page(Parameter.value(`page`))).casted() } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for getMyCourses(page: Int). Use given") - Failure("Stub return value not specified for getMyCourses(page: Int). Use given") + onFatalFailure("Stub return value not specified for getEnrollments(page: Int). Use given") + Failure("Stub return value not specified for getEnrollments(page: Int). Use given") } catch { throw error } return __value } - open func discoveryOffline() throws -> [CourseItem] { - addInvocation(.m_discoveryOffline) - let perform = methodPerformValue(.m_discoveryOffline) as? () -> Void + open func getEnrollmentsOffline() throws -> [CourseItem] { + addInvocation(.m_getEnrollmentsOffline) + let perform = methodPerformValue(.m_getEnrollmentsOffline) as? () -> Void perform?() var __value: [CourseItem] do { - __value = try methodReturnValue(.m_discoveryOffline).casted() + __value = try methodReturnValue(.m_getEnrollmentsOffline).casted() } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for discoveryOffline(). Use given") - Failure("Stub return value not specified for discoveryOffline(). Use given") + onFatalFailure("Stub return value not specified for getEnrollmentsOffline(). Use given") + Failure("Stub return value not specified for getEnrollmentsOffline(). Use given") + } catch { + throw error + } + return __value + } + + open func getPrimaryEnrollment(pageSize: Int) throws -> PrimaryEnrollment { + addInvocation(.m_getPrimaryEnrollment__pageSize_pageSize(Parameter.value(`pageSize`))) + let perform = methodPerformValue(.m_getPrimaryEnrollment__pageSize_pageSize(Parameter.value(`pageSize`))) as? (Int) -> Void + perform?(`pageSize`) + var __value: PrimaryEnrollment + do { + __value = try methodReturnValue(.m_getPrimaryEnrollment__pageSize_pageSize(Parameter.value(`pageSize`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for getPrimaryEnrollment(pageSize: Int). Use given") + Failure("Stub return value not specified for getPrimaryEnrollment(pageSize: Int). Use given") + } catch { + throw error + } + return __value + } + + open func getPrimaryEnrollmentOffline() throws -> PrimaryEnrollment { + addInvocation(.m_getPrimaryEnrollmentOffline) + let perform = methodPerformValue(.m_getPrimaryEnrollmentOffline) as? () -> Void + perform?() + var __value: PrimaryEnrollment + do { + __value = try methodReturnValue(.m_getPrimaryEnrollmentOffline).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for getPrimaryEnrollmentOffline(). Use given") + Failure("Stub return value not specified for getPrimaryEnrollmentOffline(). Use given") + } catch { + throw error + } + return __value + } + + open func getAllCourses(filteredBy: String, page: Int) throws -> PrimaryEnrollment { + addInvocation(.m_getAllCourses__filteredBy_filteredBypage_page(Parameter.value(`filteredBy`), Parameter.value(`page`))) + let perform = methodPerformValue(.m_getAllCourses__filteredBy_filteredBypage_page(Parameter.value(`filteredBy`), Parameter.value(`page`))) as? (String, Int) -> Void + perform?(`filteredBy`, `page`) + var __value: PrimaryEnrollment + do { + __value = try methodReturnValue(.m_getAllCourses__filteredBy_filteredBypage_page(Parameter.value(`filteredBy`), Parameter.value(`page`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for getAllCourses(filteredBy: String, page: Int). Use given") + Failure("Stub return value not specified for getAllCourses(filteredBy: String, page: Int). Use given") } catch { throw error } @@ -1643,31 +1691,53 @@ open class DashboardInteractorProtocolMock: DashboardInteractorProtocol, Mock { fileprivate enum MethodType { - case m_getMyCourses__page_page(Parameter) - case m_discoveryOffline + case m_getEnrollments__page_page(Parameter) + case m_getEnrollmentsOffline + case m_getPrimaryEnrollment__pageSize_pageSize(Parameter) + case m_getPrimaryEnrollmentOffline + case m_getAllCourses__filteredBy_filteredBypage_page(Parameter, Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { - case (.m_getMyCourses__page_page(let lhsPage), .m_getMyCourses__page_page(let rhsPage)): + case (.m_getEnrollments__page_page(let lhsPage), .m_getEnrollments__page_page(let rhsPage)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsPage, rhs: rhsPage, with: matcher), lhsPage, rhsPage, "page")) return Matcher.ComparisonResult(results) - case (.m_discoveryOffline, .m_discoveryOffline): return .match + case (.m_getEnrollmentsOffline, .m_getEnrollmentsOffline): return .match + + case (.m_getPrimaryEnrollment__pageSize_pageSize(let lhsPagesize), .m_getPrimaryEnrollment__pageSize_pageSize(let rhsPagesize)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsPagesize, rhs: rhsPagesize, with: matcher), lhsPagesize, rhsPagesize, "pageSize")) + return Matcher.ComparisonResult(results) + + case (.m_getPrimaryEnrollmentOffline, .m_getPrimaryEnrollmentOffline): return .match + + case (.m_getAllCourses__filteredBy_filteredBypage_page(let lhsFilteredby, let lhsPage), .m_getAllCourses__filteredBy_filteredBypage_page(let rhsFilteredby, let rhsPage)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsFilteredby, rhs: rhsFilteredby, with: matcher), lhsFilteredby, rhsFilteredby, "filteredBy")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsPage, rhs: rhsPage, with: matcher), lhsPage, rhsPage, "page")) + return Matcher.ComparisonResult(results) default: return .none } } func intValue() -> Int { switch self { - case let .m_getMyCourses__page_page(p0): return p0.intValue - case .m_discoveryOffline: return 0 + case let .m_getEnrollments__page_page(p0): return p0.intValue + case .m_getEnrollmentsOffline: return 0 + case let .m_getPrimaryEnrollment__pageSize_pageSize(p0): return p0.intValue + case .m_getPrimaryEnrollmentOffline: return 0 + case let .m_getAllCourses__filteredBy_filteredBypage_page(p0, p1): return p0.intValue + p1.intValue } } func assertionName() -> String { switch self { - case .m_getMyCourses__page_page: return ".getMyCourses(page:)" - case .m_discoveryOffline: return ".discoveryOffline()" + case .m_getEnrollments__page_page: return ".getEnrollments(page:)" + case .m_getEnrollmentsOffline: return ".getEnrollmentsOffline()" + case .m_getPrimaryEnrollment__pageSize_pageSize: return ".getPrimaryEnrollment(pageSize:)" + case .m_getPrimaryEnrollmentOffline: return ".getPrimaryEnrollmentOffline()" + case .m_getAllCourses__filteredBy_filteredBypage_page: return ".getAllCourses(filteredBy:page:)" } } } @@ -1681,50 +1751,101 @@ open class DashboardInteractorProtocolMock: DashboardInteractorProtocol, Mock { } - public static func getMyCourses(page: Parameter, willReturn: [CourseItem]...) -> MethodStub { - return Given(method: .m_getMyCourses__page_page(`page`), products: willReturn.map({ StubProduct.return($0 as Any) })) + public static func getEnrollments(page: Parameter, willReturn: [CourseItem]...) -> MethodStub { + return Given(method: .m_getEnrollments__page_page(`page`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getEnrollmentsOffline(willReturn: [CourseItem]...) -> MethodStub { + return Given(method: .m_getEnrollmentsOffline, products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func discoveryOffline(willReturn: [CourseItem]...) -> MethodStub { - return Given(method: .m_discoveryOffline, products: willReturn.map({ StubProduct.return($0 as Any) })) + public static func getPrimaryEnrollment(pageSize: Parameter, willReturn: PrimaryEnrollment...) -> MethodStub { + return Given(method: .m_getPrimaryEnrollment__pageSize_pageSize(`pageSize`), products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func getMyCourses(page: Parameter, willThrow: Error...) -> MethodStub { - return Given(method: .m_getMyCourses__page_page(`page`), products: willThrow.map({ StubProduct.throw($0) })) + public static func getPrimaryEnrollmentOffline(willReturn: PrimaryEnrollment...) -> MethodStub { + return Given(method: .m_getPrimaryEnrollmentOffline, products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func getMyCourses(page: Parameter, willProduce: (StubberThrows<[CourseItem]>) -> Void) -> MethodStub { + public static func getAllCourses(filteredBy: Parameter, page: Parameter, willReturn: PrimaryEnrollment...) -> MethodStub { + return Given(method: .m_getAllCourses__filteredBy_filteredBypage_page(`filteredBy`, `page`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getEnrollments(page: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_getEnrollments__page_page(`page`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getEnrollments(page: Parameter, willProduce: (StubberThrows<[CourseItem]>) -> Void) -> MethodStub { let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_getMyCourses__page_page(`page`), products: willThrow.map({ StubProduct.throw($0) })) }() + let given: Given = { return Given(method: .m_getEnrollments__page_page(`page`), products: willThrow.map({ StubProduct.throw($0) })) }() let stubber = given.stubThrows(for: ([CourseItem]).self) willProduce(stubber) return given } - public static func discoveryOffline(willThrow: Error...) -> MethodStub { - return Given(method: .m_discoveryOffline, products: willThrow.map({ StubProduct.throw($0) })) + public static func getEnrollmentsOffline(willThrow: Error...) -> MethodStub { + return Given(method: .m_getEnrollmentsOffline, products: willThrow.map({ StubProduct.throw($0) })) } - public static func discoveryOffline(willProduce: (StubberThrows<[CourseItem]>) -> Void) -> MethodStub { + public static func getEnrollmentsOffline(willProduce: (StubberThrows<[CourseItem]>) -> Void) -> MethodStub { let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_discoveryOffline, products: willThrow.map({ StubProduct.throw($0) })) }() + let given: Given = { return Given(method: .m_getEnrollmentsOffline, products: willThrow.map({ StubProduct.throw($0) })) }() let stubber = given.stubThrows(for: ([CourseItem]).self) willProduce(stubber) return given } + public static func getPrimaryEnrollment(pageSize: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_getPrimaryEnrollment__pageSize_pageSize(`pageSize`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getPrimaryEnrollment(pageSize: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_getPrimaryEnrollment__pageSize_pageSize(`pageSize`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (PrimaryEnrollment).self) + willProduce(stubber) + return given + } + public static func getPrimaryEnrollmentOffline(willThrow: Error...) -> MethodStub { + return Given(method: .m_getPrimaryEnrollmentOffline, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getPrimaryEnrollmentOffline(willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_getPrimaryEnrollmentOffline, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (PrimaryEnrollment).self) + willProduce(stubber) + return given + } + public static func getAllCourses(filteredBy: Parameter, page: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_getAllCourses__filteredBy_filteredBypage_page(`filteredBy`, `page`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getAllCourses(filteredBy: Parameter, page: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_getAllCourses__filteredBy_filteredBypage_page(`filteredBy`, `page`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (PrimaryEnrollment).self) + willProduce(stubber) + return given + } } public struct Verify { fileprivate var method: MethodType - public static func getMyCourses(page: Parameter) -> Verify { return Verify(method: .m_getMyCourses__page_page(`page`))} - public static func discoveryOffline() -> Verify { return Verify(method: .m_discoveryOffline)} + public static func getEnrollments(page: Parameter) -> Verify { return Verify(method: .m_getEnrollments__page_page(`page`))} + public static func getEnrollmentsOffline() -> Verify { return Verify(method: .m_getEnrollmentsOffline)} + public static func getPrimaryEnrollment(pageSize: Parameter) -> Verify { return Verify(method: .m_getPrimaryEnrollment__pageSize_pageSize(`pageSize`))} + public static func getPrimaryEnrollmentOffline() -> Verify { return Verify(method: .m_getPrimaryEnrollmentOffline)} + public static func getAllCourses(filteredBy: Parameter, page: Parameter) -> Verify { return Verify(method: .m_getAllCourses__filteredBy_filteredBypage_page(`filteredBy`, `page`))} } public struct Perform { fileprivate var method: MethodType var performs: Any - public static func getMyCourses(page: Parameter, perform: @escaping (Int) -> Void) -> Perform { - return Perform(method: .m_getMyCourses__page_page(`page`), performs: perform) + public static func getEnrollments(page: Parameter, perform: @escaping (Int) -> Void) -> Perform { + return Perform(method: .m_getEnrollments__page_page(`page`), performs: perform) + } + public static func getEnrollmentsOffline(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getEnrollmentsOffline, performs: perform) + } + public static func getPrimaryEnrollment(pageSize: Parameter, perform: @escaping (Int) -> Void) -> Perform { + return Perform(method: .m_getPrimaryEnrollment__pageSize_pageSize(`pageSize`), performs: perform) + } + public static func getPrimaryEnrollmentOffline(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getPrimaryEnrollmentOffline, performs: perform) } - public static func discoveryOffline(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_discoveryOffline, performs: perform) + public static func getAllCourses(filteredBy: Parameter, page: Parameter, perform: @escaping (String, Int) -> Void) -> Perform { + return Perform(method: .m_getAllCourses__filteredBy_filteredBypage_page(`filteredBy`, `page`), performs: perform) } } diff --git a/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift b/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift index d3261b52d..1d3ab3db9 100644 --- a/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift +++ b/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift @@ -1,5 +1,5 @@ // -// DashboardViewModelTests.swift +// ListDashboardViewModelTests.swift // DashboardTests // // Created by  Stepanok Ivan on 18.01.2023. @@ -12,47 +12,51 @@ import XCTest import Alamofire import SwiftUI -final class DashboardViewModelTests: XCTestCase { +final class ListDashboardViewModelTests: XCTestCase { func testGetMyCoursesSuccess() async throws { let interactor = DashboardInteractorProtocolMock() let connectivity = ConnectivityProtocolMock() let analytics = DashboardAnalyticsMock() - let viewModel = DashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) + let viewModel = ListDashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) let items = [ CourseItem(name: "Test", org: "org", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "123", numPages: 2, - coursesCount: 2), + coursesCount: 2, + progressEarned: 0, + progressPossible: 0), CourseItem(name: "Test2", org: "org2", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "1243", numPages: 1, - coursesCount: 2) + coursesCount: 2, + progressEarned: 0, + progressPossible: 0) ] Given(connectivity, .isInternetAvaliable(getter: true)) - Given(interactor, .getMyCourses(page: .any, willReturn: items)) + Given(interactor, .getEnrollments(page: .any, willReturn: items)) await viewModel.getMyCourses(page: 1) - Verify(interactor, 1, .getMyCourses(page: .value(1))) + Verify(interactor, 1, .getEnrollments(page: .value(1))) XCTAssertTrue(viewModel.courses == items) XCTAssertNil(viewModel.errorMessage) @@ -63,41 +67,45 @@ final class DashboardViewModelTests: XCTestCase { let interactor = DashboardInteractorProtocolMock() let connectivity = ConnectivityProtocolMock() let analytics = DashboardAnalyticsMock() - let viewModel = DashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) + let viewModel = ListDashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) let items = [ CourseItem(name: "Test", org: "org", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "123", numPages: 2, - coursesCount: 2), + coursesCount: 2, + progressEarned: 0, + progressPossible: 0), CourseItem(name: "Test2", org: "org2", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "1243", numPages: 1, - coursesCount: 2) + coursesCount: 2, + progressEarned: 0, + progressPossible: 0) ] Given(connectivity, .isInternetAvaliable(getter: false)) - Given(interactor, .discoveryOffline(willReturn: items)) + Given(interactor, .getEnrollmentsOffline(willReturn: items)) await viewModel.getMyCourses(page: 1) - Verify(interactor, 1, .discoveryOffline()) + Verify(interactor, 1, .getEnrollmentsOffline()) XCTAssertTrue(viewModel.courses == items) XCTAssertNil(viewModel.errorMessage) @@ -108,14 +116,14 @@ final class DashboardViewModelTests: XCTestCase { let interactor = DashboardInteractorProtocolMock() let connectivity = ConnectivityProtocolMock() let analytics = DashboardAnalyticsMock() - let viewModel = DashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) + let viewModel = ListDashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) Given(connectivity, .isInternetAvaliable(getter: true)) - Given(interactor, .getMyCourses(page: .any, willThrow: NoCachedDataError()) ) + Given(interactor, .getEnrollments(page: .any, willThrow: NoCachedDataError()) ) await viewModel.getMyCourses(page: 1) - Verify(interactor, 1, .getMyCourses(page: .value(1))) + Verify(interactor, 1, .getEnrollments(page: .value(1))) XCTAssertTrue(viewModel.courses.isEmpty) XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.noCachedData) @@ -126,14 +134,14 @@ final class DashboardViewModelTests: XCTestCase { let interactor = DashboardInteractorProtocolMock() let connectivity = ConnectivityProtocolMock() let analytics = DashboardAnalyticsMock() - let viewModel = DashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) + let viewModel = ListDashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) Given(connectivity, .isInternetAvaliable(getter: true)) - Given(interactor, .getMyCourses(page: .any, willThrow: NSError()) ) + Given(interactor, .getEnrollments(page: .any, willThrow: NSError()) ) await viewModel.getMyCourses(page: 1) - Verify(interactor, 1, .getMyCourses(page: .value(1))) + Verify(interactor, 1, .getEnrollments(page: .value(1))) XCTAssertTrue(viewModel.courses.isEmpty) XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) diff --git a/Discovery/Discovery/Data/DiscoveryRepository.swift b/Discovery/Discovery/Data/DiscoveryRepository.swift index 3b20f84a3..1d6f5e0a9 100644 --- a/Discovery/Discovery/Data/DiscoveryRepository.swift +++ b/Discovery/Discovery/Data/DiscoveryRepository.swift @@ -128,13 +128,15 @@ class DiscoveryRepositoryMock: DiscoveryRepositoryProtocol { org: "Organization", shortDescription: "shortDescription", imageURL: "", - isActive: true, + hasAccess: true, courseStart: nil, courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, courseID: "course_id_\(i)", - numPages: 1, coursesCount: 10 + numPages: 1, coursesCount: 10, + progressEarned: 0, + progressPossible: 0 ) ) } @@ -150,13 +152,15 @@ class DiscoveryRepositoryMock: DiscoveryRepositoryProtocol { org: "Organization", shortDescription: "shortDescription", imageURL: "", - isActive: nil, + hasAccess: true, courseStart: nil, courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, courseID: "course_id_\(i)", - numPages: 1, coursesCount: 10 + numPages: 1, coursesCount: 10, + progressEarned: 0, + progressPossible: 0 ) ) } @@ -172,14 +176,16 @@ class DiscoveryRepositoryMock: DiscoveryRepositoryProtocol { org: "Organization", shortDescription: "shortDescription", imageURL: "", - isActive: true, + hasAccess: true, courseStart: nil, courseEnd: nil, enrollmentStart: nil, enrollmentEnd: nil, courseID: "course_id_\(i)", numPages: 1, - coursesCount: 10 + coursesCount: 10, + progressEarned: 0, + progressPossible: 0 ) ) } diff --git a/Discovery/Discovery/Data/Persistence/DiscoveryCoreModel.xcdatamodeld/DiscoveryCoreModel.xcdatamodel/contents b/Discovery/Discovery/Data/Persistence/DiscoveryCoreModel.xcdatamodeld/DiscoveryCoreModel.xcdatamodel/contents index 154df9ca8..2c838b0dd 100644 --- a/Discovery/Discovery/Data/Persistence/DiscoveryCoreModel.xcdatamodeld/DiscoveryCoreModel.xcdatamodel/contents +++ b/Discovery/Discovery/Data/Persistence/DiscoveryCoreModel.xcdatamodeld/DiscoveryCoreModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -27,8 +27,8 @@ + - diff --git a/Discovery/Discovery/Presentation/DiscoveryRouter.swift b/Discovery/Discovery/Presentation/DiscoveryRouter.swift index cca463e95..4416d9659 100644 --- a/Discovery/Discovery/Presentation/DiscoveryRouter.swift +++ b/Discovery/Discovery/Presentation/DiscoveryRouter.swift @@ -20,12 +20,14 @@ public protocol DiscoveryRouter: BaseRouter { func showDiscoverySearch(searchQuery: String?) func showCourseScreens( courseID: String, - isActive: Bool?, + hasAccess: Bool?, courseStart: Date?, courseEnd: Date?, enrollmentStart: Date?, enrollmentEnd: Date?, - title: String + title: String, + showDates: Bool, + lastVisitedBlockID: String? ) func showWebProgramDetails( @@ -51,12 +53,14 @@ public class DiscoveryRouterMock: BaseRouterMock, DiscoveryRouter { public func showDiscoverySearch(searchQuery: String? = nil) {} public func showCourseScreens( courseID: String, - isActive: Bool?, + hasAccess: Bool?, courseStart: Date?, courseEnd: Date?, enrollmentStart: Date?, enrollmentEnd: Date?, - title: String + title: String, + showDates: Bool, + lastVisitedBlockID: String? ) {} public func showWebProgramDetails( diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift index 2300433ef..80864b8fd 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift @@ -275,12 +275,14 @@ private struct CourseStateView: View { ) viewModel.router.showCourseScreens( courseID: courseDetails.courseID, - isActive: nil, + hasAccess: nil, courseStart: courseDetails.courseStart, courseEnd: courseDetails.courseEnd, enrollmentStart: courseDetails.enrollmentStart, enrollmentEnd: courseDetails.enrollmentEnd, - title: title + title: title, + showDates: false, + lastVisitedBlockID: nil ) } }) diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift index 265631697..99d7ecea9 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift @@ -173,8 +173,8 @@ public struct SearchView: View { .onDisappear { viewModel.searchText = "" } - .background(Theme.Colors.background.ignoresSafeArea()) .avoidKeyboard(dismissKeyboardByTap: true) + .background(Theme.Colors.background.ignoresSafeArea()) } } diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift index 18e91733b..836323072 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift @@ -231,12 +231,14 @@ extension DiscoveryWebviewViewModel: WebViewNavigationDelegate { router.showCourseScreens( courseID: courseDetails.courseID, - isActive: nil, + hasAccess: nil, courseStart: courseDetails.courseStart, courseEnd: courseDetails.courseEnd, enrollmentStart: courseDetails.enrollmentStart, enrollmentEnd: courseDetails.enrollmentEnd, - title: courseDetails.courseTitle + title: courseDetails.courseTitle, + showDates: false, + lastVisitedBlockID: nil ) return true diff --git a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift index 82c234882..ad0c89987 100644 --- a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift +++ b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift @@ -219,12 +219,14 @@ extension ProgramWebviewViewModel: WebViewNavigationDelegate { router.showCourseScreens( courseID: courseDetails.courseID, - isActive: nil, + hasAccess: nil, courseStart: courseDetails.courseStart, courseEnd: courseDetails.courseEnd, enrollmentStart: courseDetails.enrollmentStart, enrollmentEnd: courseDetails.enrollmentEnd, - title: courseDetails.courseTitle + title: courseDetails.courseTitle, + showDates: false, + lastVisitedBlockID: nil ) return true diff --git a/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift b/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift index 30b6b5706..241178b03 100644 --- a/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift +++ b/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift @@ -38,26 +38,30 @@ final class DiscoveryViewModelTests: XCTestCase { org: "org", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "123", numPages: 2, - coursesCount: 2), + coursesCount: 2, + progressEarned: 0, + progressPossible: 0), CourseItem(name: "Test2", org: "org2", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "1243", numPages: 1, - coursesCount: 2) + coursesCount: 2, + progressEarned: 0, + progressPossible: 0) ] viewModel.courses = items + items + items viewModel.totalPages = 2 @@ -87,26 +91,30 @@ final class DiscoveryViewModelTests: XCTestCase { org: "org", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "123", numPages: 2, - coursesCount: 0), + coursesCount: 0, + progressEarned: 0, + progressPossible: 0), CourseItem(name: "Test2", org: "org2", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "1243", numPages: 1, - coursesCount: 0) + coursesCount: 0, + progressEarned: 0, + progressPossible: 0) ] Given(interactor, .discovery(page: 1, willReturn: items)) @@ -135,26 +143,30 @@ final class DiscoveryViewModelTests: XCTestCase { org: "org", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "123", numPages: 2, - coursesCount: 2), + coursesCount: 2, + progressEarned: 0, + progressPossible: 0), CourseItem(name: "Test2", org: "org2", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "1243", numPages: 1, - coursesCount: 2) + coursesCount: 2, + progressEarned: 0, + progressPossible: 0) ] Given(connectivity, .isInternetAvaliable(getter: false)) diff --git a/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift b/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift index 3baf45321..e1596add9 100644 --- a/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift +++ b/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift @@ -40,26 +40,30 @@ final class SearchViewModelTests: XCTestCase { org: "org", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "123", numPages: 2, - coursesCount: 0), + coursesCount: 0, + progressEarned: 0, + progressPossible: 0), CourseItem(name: "Test2", org: "org2", shortDescription: "", imageURL: "", - isActive: true, + hasAccess: true, courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), enrollmentEnd: Date(), courseID: "1243", numPages: 1, - coursesCount: 0) + coursesCount: 0, + progressEarned: 0, + progressPossible: 0) ] Given(interactor, .search(page: 1, searchTerm: .any, willReturn: items)) diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 2c94092fc..8341e2233 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -44,8 +44,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 */; }; + 1924EDE8164C7AB17AD4946B /* Pods_App_OpenEdX.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FC49621A0942E5EE74BDC895 /* 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 */; }; @@ -128,9 +128,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 = ""; }; - 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 = ""; }; - 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 = ""; }; + 1BB1F1D0FABF8788646FBAF2 /* 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 = ""; }; + 37C50995093E34142FDE0ED9 /* 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 = ""; }; 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,13 +142,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 = ""; }; + A681C3929FC384F83BCB6648 /* 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 = ""; }; + AA8BE99557031F3F33F8037C /* 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 = ""; }; - 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 = ""; }; + DAD1882A21DDAF1F67E4C546 /* 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 = ""; }; E0D6E6A22B1626B10089F9C9 /* Theme.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Theme.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; }; + FC49621A0942E5EE74BDC895 /* Pods_App_OpenEdX.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_OpenEdX.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + FC8F87F82A110A0F7A1B0725 /* 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -170,7 +170,7 @@ A5BD3E302B83B0F7006A8983 /* SegmentFirebase in Frameworks */, A51CDBED2B6D2BEE009B6D4E /* SegmentBraze in Frameworks */, A51CDBEF2B6D2BEE009B6D4E /* SegmentBrazeUI in Frameworks */, - 0AF04487E2B198E7A14F037F /* Pods_App_OpenEdX.framework in Frameworks */, + 1924EDE8164C7AB17AD4946B /* Pods_App_OpenEdX.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -263,7 +263,7 @@ 072787B028D34D83002E9142 /* Discovery.framework */, 0770DE4A28D0A462006D8A5D /* Authorization.framework */, 0770DE1228D07845006D8A5D /* Core.framework */, - FEBF1DCBDEE79A04144660EB /* Pods_App_OpenEdX.framework */, + FC49621A0942E5EE74BDC895 /* Pods_App_OpenEdX.framework */, ); name = Frameworks; sourceTree = ""; @@ -271,12 +271,12 @@ 55A895025FB07897BA68E063 /* Pods */ = { isa = PBXGroup; children = ( - 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 */, + A681C3929FC384F83BCB6648 /* Pods-App-OpenEdX.debugprod.xcconfig */, + 1BB1F1D0FABF8788646FBAF2 /* Pods-App-OpenEdX.debugstage.xcconfig */, + DAD1882A21DDAF1F67E4C546 /* Pods-App-OpenEdX.debugdev.xcconfig */, + FC8F87F82A110A0F7A1B0725 /* Pods-App-OpenEdX.releaseprod.xcconfig */, + AA8BE99557031F3F33F8037C /* Pods-App-OpenEdX.releasestage.xcconfig */, + 37C50995093E34142FDE0ED9 /* Pods-App-OpenEdX.releasedev.xcconfig */, ); path = Pods; sourceTree = ""; @@ -390,7 +390,7 @@ isa = PBXNativeTarget; buildConfigurationList = 07D5DA4528D075AB00752FD9 /* Build configuration list for PBXNativeTarget "OpenEdX" */; buildPhases = ( - CC61D82AF6FA18BD30E5E8E5 /* [CP] Check Pods Manifest.lock */, + B9442FD26CE9A85A43FC2CFA /* [CP] Check Pods Manifest.lock */, 0770DE2328D08647006D8A5D /* SwiftLint */, 07D5DA2D28D075AA00752FD9 /* Sources */, 07D5DA2E28D075AA00752FD9 /* Frameworks */, @@ -512,7 +512,7 @@ shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/SwiftLint/swiftlint\"\n"; }; - CC61D82AF6FA18BD30E5E8E5 /* [CP] Check Pods Manifest.lock */ = { + B9442FD26CE9A85A43FC2CFA /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -695,7 +695,7 @@ }; 02DD1C9629E80CC200F35DCE /* DebugStage */ = { isa = XCBuildConfiguration; - baseConfigurationReference = E02715409EC1643835C9EFEE /* Pods-App-OpenEdX.debugstage.xcconfig */; + baseConfigurationReference = 1BB1F1D0FABF8788646FBAF2 /* Pods-App-OpenEdX.debugstage.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -783,7 +783,7 @@ }; 02DD1C9829E80CCB00F35DCE /* ReleaseStage */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 0F08B37CE833845FF5CD43E0 /* Pods-App-OpenEdX.releasestage.xcconfig */; + baseConfigurationReference = AA8BE99557031F3F33F8037C /* Pods-App-OpenEdX.releasestage.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -877,7 +877,7 @@ }; 0727875928D231FD002E9142 /* DebugDev */ = { isa = XCBuildConfiguration; - baseConfigurationReference = BEDEC3C1F88936685DCF7AE5 /* Pods-App-OpenEdX.debugdev.xcconfig */; + baseConfigurationReference = DAD1882A21DDAF1F67E4C546 /* Pods-App-OpenEdX.debugdev.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -965,7 +965,7 @@ }; 0727875B28D23204002E9142 /* ReleaseDev */ = { isa = XCBuildConfiguration; - baseConfigurationReference = D19FBC727E9AD036986A0B8D /* Pods-App-OpenEdX.releasedev.xcconfig */; + baseConfigurationReference = 37C50995093E34142FDE0ED9 /* Pods-App-OpenEdX.releasedev.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -1113,7 +1113,7 @@ }; 07D5DA4628D075AB00752FD9 /* DebugProd */ = { isa = XCBuildConfiguration; - baseConfigurationReference = C9D8705F03FC185BBE66984C /* Pods-App-OpenEdX.debugprod.xcconfig */; + baseConfigurationReference = A681C3929FC384F83BCB6648 /* Pods-App-OpenEdX.debugprod.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; @@ -1147,7 +1147,7 @@ }; 07D5DA4728D075AB00752FD9 /* ReleaseProd */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 5E9E8EEB795809CB9424EBA6 /* Pods-App-OpenEdX.releaseprod.xcconfig */; + baseConfigurationReference = FC8F87F82A110A0F7A1B0725 /* Pods-App-OpenEdX.releaseprod.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 3ef1aac7a..44b421731 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -162,8 +162,25 @@ class ScreenAssembly: Assembly { repository: r.resolve(DashboardRepositoryProtocol.self)! ) } - container.register(DashboardViewModel.self) { r in - DashboardViewModel( + container.register(ListDashboardViewModel.self) { r in + ListDashboardViewModel( + interactor: r.resolve(DashboardInteractorProtocol.self)!, + connectivity: r.resolve(ConnectivityProtocol.self)!, + analytics: r.resolve(DashboardAnalytics.self)! + ) + } + + container.register(PrimaryCourseDashboardViewModel.self) { r in + PrimaryCourseDashboardViewModel( + interactor: r.resolve(DashboardInteractorProtocol.self)!, + connectivity: r.resolve(ConnectivityProtocol.self)!, + analytics: r.resolve(DashboardAnalytics.self)!, + config: r.resolve(ConfigProtocol.self)! + ) + } + + container.register(AllCoursesViewModel.self) { r in + AllCoursesViewModel( interactor: r.resolve(DashboardInteractorProtocol.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, analytics: r.resolve(DashboardAnalytics.self)! @@ -274,7 +291,7 @@ class ScreenAssembly: Assembly { // MARK: CourseScreensView container.register( CourseContainerViewModel.self - ) { r, isActive, courseStart, courseEnd, enrollmentStart, enrollmentEnd in + ) { r, isActive, courseStart, courseEnd, enrollmentStart, enrollmentEnd, selection, lastVisitedBlockID in CourseContainerViewModel( interactor: r.resolve(CourseInteractorProtocol.self)!, authInteractor: r.resolve(AuthInteractorProtocol.self)!, @@ -289,7 +306,9 @@ class ScreenAssembly: Assembly { courseEnd: courseEnd, enrollmentStart: enrollmentStart, enrollmentEnd: enrollmentEnd, - coreAnalytics: r.resolve(CoreAnalytics.self)! + lastVisitedBlockID: lastVisitedBlockID, + coreAnalytics: r.resolve(CoreAnalytics.self)!, + selection: selection ) } diff --git a/OpenEdX/Data/CoursePersistence.swift b/OpenEdX/Data/CoursePersistence.swift index e2fc37e54..74005ca53 100644 --- a/OpenEdX/Data/CoursePersistence.swift +++ b/OpenEdX/Data/CoursePersistence.swift @@ -25,14 +25,16 @@ public class CoursePersistence: CoursePersistenceProtocol { org: $0.org ?? "", shortDescription: $0.desc ?? "", imageURL: $0.imageURL ?? "", - isActive: $0.isActive, + hasAccess: $0.hasAccess, courseStart: $0.courseStart, courseEnd: $0.courseEnd, enrollmentStart: $0.enrollmentStart, enrollmentEnd: $0.enrollmentEnd, courseID: $0.courseID ?? "", numPages: Int($0.numPages), - coursesCount: Int($0.courseCount))} + coursesCount: Int($0.courseCount), + progressEarned: 0, + progressPossible: 0)} if let result, !result.isEmpty { return result } else { @@ -48,9 +50,7 @@ public class CoursePersistence: CoursePersistenceProtocol { newItem.org = item.org newItem.desc = item.shortDescription newItem.imageURL = item.imageURL - if let isActive = item.isActive { - newItem.isActive = isActive - } + newItem.hasAccess = item.hasAccess newItem.courseStart = item.courseStart newItem.courseEnd = item.courseEnd newItem.enrollmentStart = item.enrollmentStart @@ -146,6 +146,7 @@ public class CoursePersistence: CoursePersistenceProtocol { public func saveCourseStructure(structure: DataLayer.CourseStructure) { context.performAndWait { + context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump let newStructure = CDCourseStructure(context: self.context) newStructure.certificate = structure.certificate?.url newStructure.mediaSmall = structure.media.image.small diff --git a/OpenEdX/Data/DashboardPersistence.swift b/OpenEdX/Data/DashboardPersistence.swift index 06241c00f..b7e0f062a 100644 --- a/OpenEdX/Data/DashboardPersistence.swift +++ b/OpenEdX/Data/DashboardPersistence.swift @@ -18,20 +18,22 @@ public class DashboardPersistence: DashboardPersistenceProtocol { self.context = context } - public func loadMyCourses() throws -> [CourseItem] { + public func loadEnrollments() throws -> [CourseItem] { let result = try? context.fetch(CDDashboardCourse.fetchRequest()) .map { CourseItem(name: $0.name ?? "", org: $0.org ?? "", shortDescription: $0.desc ?? "", imageURL: $0.imageURL ?? "", - isActive: nil, + hasAccess: $0.hasAccess, courseStart: $0.courseStart, courseEnd: $0.courseEnd, enrollmentStart: $0.enrollmentStart, enrollmentEnd: $0.enrollmentEnd, courseID: $0.courseID ?? "", numPages: Int($0.numPages), - coursesCount: Int($0.courseCount))} + coursesCount: Int($0.courseCount), + progressEarned: 0, + progressPossible: 0)} if let result, !result.isEmpty { return result } else { @@ -39,15 +41,16 @@ public class DashboardPersistence: DashboardPersistenceProtocol { } } - public func saveMyCourses(items: [CourseItem]) { + public func saveEnrollments(items: [CourseItem]) { for item in items { context.performAndWait { - let newItem = CDDashboardCourse(context: context) + let newItem = CDDashboardCourse(context: self.context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump newItem.name = item.name newItem.org = item.org newItem.desc = item.shortDescription newItem.imageURL = item.imageURL + newItem.hasAccess = item.hasAccess newItem.courseStart = item.courseStart newItem.courseEnd = item.courseEnd newItem.enrollmentStart = item.enrollmentStart @@ -63,4 +66,183 @@ public class DashboardPersistence: DashboardPersistenceProtocol { } } } + + public func loadPrimaryEnrollment() throws -> PrimaryEnrollment { + let request = CDMyEnrollments.fetchRequest() + if let result = try context.fetch(request).first { + let primaryCourse = result.primaryCourse.flatMap { cdPrimaryCourse -> PrimaryCourse? in + + let futureAssignments = (cdPrimaryCourse.futureAssignments as? Set ?? []) + .map { future in + return Assignment( + type: future.type ?? "", + title: future.title ?? "", + description: future.descript ?? "", + date: future.date ?? Date(), + complete: future.complete, + firstComponentBlockId: future.firstComponentBlockId + ) + } + + let pastAssignments = (cdPrimaryCourse.pastAssignments as? Set ?? []) + .map { past in + return Assignment( + type: past.type ?? "", + title: past.title ?? "", + description: past.descript ?? "", + date: past.date ?? Date(), + complete: past.complete, + firstComponentBlockId: past.firstComponentBlockId + ) + } + + return PrimaryCourse( + name: cdPrimaryCourse.name ?? "", + org: cdPrimaryCourse.org ?? "", + courseID: cdPrimaryCourse.courseID ?? "", + hasAccess: cdPrimaryCourse.hasAccess, + courseStart: cdPrimaryCourse.courseStart, + courseEnd: cdPrimaryCourse.courseEnd, + courseBanner: cdPrimaryCourse.courseBanner ?? "", + futureAssignments: futureAssignments, + pastAssignments: pastAssignments, + progressEarned: Int(cdPrimaryCourse.progressEarned), + progressPossible: Int(cdPrimaryCourse.progressPossible), + lastVisitedBlockID: cdPrimaryCourse.lastVisitedBlockID ?? "", + resumeTitle: cdPrimaryCourse.resumeTitle + ) + } + + let courses = (result.courses as? Set ?? []) + .map { cdCourse in + return CourseItem( + name: cdCourse.name ?? "", + org: cdCourse.org ?? "", + shortDescription: cdCourse.desc ?? "", + imageURL: cdCourse.imageURL ?? "", + hasAccess: cdCourse.hasAccess, + courseStart: cdCourse.courseStart, + courseEnd: cdCourse.courseEnd, + enrollmentStart: cdCourse.enrollmentStart, + enrollmentEnd: cdCourse.enrollmentEnd, + courseID: cdCourse.courseID ?? "", + numPages: Int(cdCourse.numPages), + coursesCount: Int(cdCourse.courseCount), + progressEarned: Int(cdCourse.progressEarned), + progressPossible: Int(cdCourse.progressPossible) + ) + } + + return PrimaryEnrollment( + primaryCourse: primaryCourse, + courses: courses, + totalPages: Int(result.totalPages), + count: Int(result.count) + ) + } else { + throw NoCachedDataError() + } + } + + // swiftlint:disable function_body_length + public func savePrimaryEnrollment(enrollments: PrimaryEnrollment) { + context.performAndWait { + // Deleting all old data before saving new ones + clearOldEnrollmentsData() + + let newEnrollment = CDMyEnrollments(context: context) + context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + + // Saving new courses + newEnrollment.courses = NSSet(array: enrollments.courses.map { course in + let cdCourse = CDDashboardCourse(context: self.context) + cdCourse.name = course.name + cdCourse.org = course.org + cdCourse.desc = course.shortDescription + cdCourse.imageURL = course.imageURL + cdCourse.courseStart = course.courseStart + cdCourse.courseEnd = course.courseEnd + cdCourse.enrollmentStart = course.enrollmentStart + cdCourse.enrollmentEnd = course.enrollmentEnd + cdCourse.courseID = course.courseID + cdCourse.numPages = Int32(course.numPages) + cdCourse.hasAccess = course.hasAccess + cdCourse.progressEarned = Int32(course.progressEarned) + cdCourse.progressPossible = Int32(course.progressPossible) + return cdCourse + }) + + // Saving PrimaryCourse + if let primaryCourse = enrollments.primaryCourse { + let cdPrimaryCourse = CDPrimaryCourse(context: context) + + let futureAssignments = primaryCourse.futureAssignments.map { assignment in + let cdAssignment = CDAssignment(context: self.context) + cdAssignment.type = assignment.type + cdAssignment.title = assignment.title + cdAssignment.descript = assignment.description + cdAssignment.date = assignment.date + cdAssignment.complete = assignment.complete + cdAssignment.firstComponentBlockId = assignment.firstComponentBlockId + return cdAssignment + } + cdPrimaryCourse.futureAssignments = NSSet(array: futureAssignments) + + let pastAssignments = primaryCourse.pastAssignments.map { assignment in + let cdAssignment = CDAssignment(context: self.context) + cdAssignment.type = assignment.type + cdAssignment.title = assignment.title + cdAssignment.descript = assignment.description + cdAssignment.date = assignment.date + cdAssignment.complete = assignment.complete + cdAssignment.firstComponentBlockId = assignment.firstComponentBlockId + return cdAssignment + } + cdPrimaryCourse.pastAssignments = NSSet(array: pastAssignments) + + cdPrimaryCourse.name = primaryCourse.name + cdPrimaryCourse.org = primaryCourse.org + cdPrimaryCourse.courseID = primaryCourse.courseID + cdPrimaryCourse.hasAccess = primaryCourse.hasAccess + cdPrimaryCourse.courseStart = primaryCourse.courseStart + cdPrimaryCourse.courseEnd = primaryCourse.courseEnd + cdPrimaryCourse.courseBanner = primaryCourse.courseBanner + cdPrimaryCourse.progressEarned = Int32(primaryCourse.progressEarned) + cdPrimaryCourse.progressPossible = Int32(primaryCourse.progressPossible) + cdPrimaryCourse.lastVisitedBlockID = primaryCourse.lastVisitedBlockID + cdPrimaryCourse.resumeTitle = primaryCourse.resumeTitle + + newEnrollment.primaryCourse = cdPrimaryCourse + } + + newEnrollment.totalPages = Int32(enrollments.totalPages) + newEnrollment.count = Int32(enrollments.count) + + do { + try context.save() + } catch { + print("Error when saving MyEnrollments:", error) + } + } + } + // swiftlint:enable function_body_length + + func clearOldEnrollmentsData() { + let fetchRequest1: NSFetchRequest = CDDashboardCourse.fetchRequest() + let batchDeleteRequest1 = NSBatchDeleteRequest(fetchRequest: fetchRequest1) + + let fetchRequest2: NSFetchRequest = CDPrimaryCourse.fetchRequest() + let batchDeleteRequest2 = NSBatchDeleteRequest(fetchRequest: fetchRequest2) + + let fetchRequest3: NSFetchRequest = CDMyEnrollments.fetchRequest() + let batchDeleteRequest3 = NSBatchDeleteRequest(fetchRequest: fetchRequest3) + + do { + try context.execute(batchDeleteRequest1) + try context.execute(batchDeleteRequest2) + try context.execute(batchDeleteRequest3) + } catch { + print("Error when deleting old data:", error) + } + } } diff --git a/OpenEdX/Data/DiscoveryPersistence.swift b/OpenEdX/Data/DiscoveryPersistence.swift index 189264f41..2e6d443bd 100644 --- a/OpenEdX/Data/DiscoveryPersistence.swift +++ b/OpenEdX/Data/DiscoveryPersistence.swift @@ -24,14 +24,16 @@ public class DiscoveryPersistence: DiscoveryPersistenceProtocol { org: $0.org ?? "", shortDescription: $0.desc ?? "", imageURL: $0.imageURL ?? "", - isActive: $0.isActive, + hasAccess: $0.hasAccess, courseStart: $0.courseStart, courseEnd: $0.courseEnd, enrollmentStart: $0.enrollmentStart, enrollmentEnd: $0.enrollmentEnd, courseID: $0.courseID ?? "", numPages: Int($0.numPages), - coursesCount: Int($0.courseCount))} + coursesCount: Int($0.courseCount), + progressEarned: 0, + progressPossible: 0)} if let result, !result.isEmpty { return result } else { @@ -48,9 +50,7 @@ public class DiscoveryPersistence: DiscoveryPersistenceProtocol { newItem.org = item.org newItem.desc = item.shortDescription newItem.imageURL = item.imageURL - if let isActive = item.isActive { - newItem.isActive = isActive - } + newItem.hasAccess = item.hasAccess newItem.courseStart = item.courseStart newItem.courseEnd = item.courseEnd newItem.enrollmentStart = item.enrollmentStart diff --git a/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift b/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift index ae33a9d64..0ae41e30a 100644 --- a/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift +++ b/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift @@ -107,12 +107,14 @@ extension Router: DeepLinkRouter { if courseDetails.isEnrolled { showCourseScreens( courseID: courseDetails.courseID, - isActive: nil, + hasAccess: nil, courseStart: courseDetails.courseStart, courseEnd: courseDetails.courseEnd, enrollmentStart: courseDetails.enrollmentStart, enrollmentEnd: courseDetails.enrollmentEnd, - title: courseDetails.courseTitle + title: courseDetails.courseTitle, + showDates: false, + lastVisitedBlockID: nil ) } else { showCourseDetais( diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift index 636c4d101..6de11a104 100644 --- a/OpenEdX/Managers/PipManager.swift +++ b/OpenEdX/Managers/PipManager.swift @@ -6,6 +6,7 @@ // import Course +import Core import Combine import Discovery import SwiftUI @@ -178,15 +179,17 @@ public class PipManager: PipManagerProtocol { for holder: PlayerViewControllerHolderProtocol ) async throws -> UIHostingController { let courseDetails = try await getCourseDetails(for: holder) - let isActive: Bool? = nil + let hasAccess: Bool? = nil let controller = router.getCourseScreensController( courseID: courseDetails.courseID, - isActive: isActive, + hasAccess: hasAccess, courseStart: courseDetails.courseStart, courseEnd: courseDetails.courseEnd, enrollmentStart: courseDetails.enrollmentStart, enrollmentEnd: courseDetails.enrollmentEnd, - title: courseDetails.courseTitle + title: courseDetails.courseTitle, + showDates: false, + lastVisitedBlockID: nil ) controller.rootView.viewModel.selection = holder.selectedCourseTab return controller diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index a6e175dff..f14818890 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -359,41 +359,49 @@ public class Router: AuthorizationRouter, public func showCourseScreens( courseID: String, - isActive: Bool?, + hasAccess: Bool?, courseStart: Date?, courseEnd: Date?, enrollmentStart: Date?, enrollmentEnd: Date?, - title: String + title: String, + showDates: Bool, + lastVisitedBlockID: String? ) { let controller = getCourseScreensController( courseID: courseID, - isActive: isActive, + hasAccess: hasAccess, courseStart: courseStart, courseEnd: courseEnd, enrollmentStart: enrollmentStart, enrollmentEnd: enrollmentEnd, - title: title + title: title, + showDates: showDates, + lastVisitedBlockID: lastVisitedBlockID ) navigationController.pushViewController(controller, animated: true) } public func getCourseScreensController( courseID: String, - isActive: Bool?, + hasAccess: Bool?, courseStart: Date?, courseEnd: Date?, enrollmentStart: Date?, enrollmentEnd: Date?, - title: String + title: String, + showDates: Bool, + lastVisitedBlockID: String? ) -> UIHostingController { let vm = Container.shared.resolve( CourseContainerViewModel.self, - arguments: isActive, + arguments: hasAccess, courseStart, courseEnd, enrollmentStart, - enrollmentEnd + enrollmentEnd, + showDates ? CourseTab.dates : CourseTab.course, + lastVisitedBlockID )! let datesVm = Container.shared.resolve( @@ -412,6 +420,13 @@ public class Router: AuthorizationRouter, return UIHostingController(rootView: screensView) } + public func showAllCourses(courses: [CourseItem]) { + let vm = Container.shared.resolve(AllCoursesViewModel.self)! + let view = AllCoursesView(viewModel: vm, router: self) + let controller = UIHostingController(rootView: view) + navigationController.pushViewController(controller, animated: true) + } + public func showHandoutsUpdatesView( handouts: String?, announcements: [CourseUpdate]?, diff --git a/OpenEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift index 98e349542..7e3e30d60 100644 --- a/OpenEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -37,16 +37,77 @@ struct MainScreenView: View { var body: some View { TabView(selection: $viewModel.selection) { - let config = Container.shared.resolve(ConfigProtocol.self) - if config?.discovery.enabled ?? false { + switch viewModel.config.dashboard.type { + case .list: ZStack { - if config?.discovery.type == .native { + ListDashboardView( + viewModel: Container.shared.resolve(ListDashboardViewModel.self)!, + router: Container.shared.resolve(DashboardRouter.self)! + ) + + if updateAvailable { + UpdateNotificationView(config: viewModel.config) + } + } + .tabItem { + CoreAssets.dashboard.swiftUIImage.renderingMode(.template) + Text(CoreLocalization.Mainscreen.dashboard) + } + .tag(MainTab.dashboard) + .accessibilityIdentifier("dashboard_tabitem") + if viewModel.config.program.enabled { + ZStack { + if viewModel.config.program.type == .webview { + ProgramWebviewView( + viewModel: Container.shared.resolve(ProgramWebviewViewModel.self)!, + router: Container.shared.resolve(DiscoveryRouter.self)! + ) + } else if viewModel.config.program.type == .native { + Text(CoreLocalization.Mainscreen.inDeveloping) + } + + if updateAvailable { + UpdateNotificationView(config: viewModel.config) + } + } + .tabItem { + CoreAssets.programs.swiftUIImage.renderingMode(.template) + Text(CoreLocalization.Mainscreen.programs) + } + .tag(MainTab.programs) + } + case .gallery: + ZStack { + PrimaryCourseDashboardView( + viewModel: Container.shared.resolve(PrimaryCourseDashboardViewModel.self)!, + router: Container.shared.resolve(DashboardRouter.self)!, + programView: ProgramWebviewView( + viewModel: Container.shared.resolve(ProgramWebviewViewModel.self)!, + router: Container.shared.resolve(DiscoveryRouter.self)! + ), + openDiscoveryPage: { viewModel.selection = .discovery } + ) + if updateAvailable { + UpdateNotificationView(config: viewModel.config) + } + } + .tabItem { + CoreAssets.learn.swiftUIImage.renderingMode(.template) + Text(CoreLocalization.Mainscreen.learn) + } + .tag(MainTab.dashboard) + .accessibilityIdentifier("dashboard_tabitem") + } + + if viewModel.config.discovery.enabled { + ZStack { + if viewModel.config.discovery.type == .native { DiscoveryView( viewModel: Container.shared.resolve(DiscoveryViewModel.self)!, router: Container.shared.resolve(DiscoveryRouter.self)!, sourceScreen: viewModel.sourceScreen ) - } else if config?.discovery.type == .webview { + } else if viewModel.config.discovery.type == .webview { DiscoveryWebview( viewModel: Container.shared.resolve( DiscoveryWebviewViewModel.self, @@ -67,46 +128,6 @@ struct MainScreenView: View { .accessibilityIdentifier("discovery_tabitem") } - ZStack { - DashboardView( - viewModel: Container.shared.resolve(DashboardViewModel.self)!, - router: Container.shared.resolve(DashboardRouter.self)! - ) - if updateAvailable { - UpdateNotificationView(config: viewModel.config) - } - } - .tabItem { - CoreAssets.dashboard.swiftUIImage.renderingMode(.template) - Text(CoreLocalization.Mainscreen.dashboard) - } - .tag(MainTab.dashboard) - .accessibilityIdentifier("dashboard_tabitem") - - if config?.program.enabled ?? false { - ZStack { - if config?.program.type == .webview { - ProgramWebviewView( - viewModel: Container.shared.resolve(ProgramWebviewViewModel.self)!, - router: Container.shared.resolve(DiscoveryRouter.self)! - ) - } else if config?.program.type == .native { - Text(CoreLocalization.Mainscreen.inDeveloping) - .accessibilityIdentifier("indevelopment_program_text") - } - - if updateAvailable { - UpdateNotificationView(config: viewModel.config) - } - } - .tabItem { - CoreAssets.programs.swiftUIImage.renderingMode(.template) - Text(CoreLocalization.Mainscreen.programs) - } - .tag(MainTab.programs) - .accessibilityIdentifier("programs_tabitem") - } - VStack { ProfileView( viewModel: Container.shared.resolve(ProfileViewModel.self)! @@ -119,8 +140,8 @@ struct MainScreenView: View { .tag(MainTab.profile) .accessibilityIdentifier("profile_tabitem") } - .navigationBarHidden(false) - .navigationBarBackButtonHidden(false) + .navigationBarHidden(viewModel.selection == .dashboard) + .navigationBarBackButtonHidden(viewModel.selection == .dashboard) .navigationTitle(titleBar()) .toolbar { ToolbarItem(placement: .navigationBarTrailing, content: { @@ -171,7 +192,9 @@ struct MainScreenView: View { case .discovery: return DiscoveryLocalization.title case .dashboard: - return DashboardLocalization.title + return viewModel.config.dashboard.type == .list + ? DashboardLocalization.title + : DashboardLocalization.Learn.title case .programs: return CoreLocalization.Mainscreen.programs case .profile: diff --git a/Theme/Theme/Assets.xcassets/Colors/AccentColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/AccentColor.colorset/Contents.json index 00d59cb46..bf1a96417 100644 --- a/Theme/Theme/Assets.xcassets/Colors/AccentColor.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/AccentColor.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.976", - "green" : "0.471", - "red" : "0.329" + "blue" : "0xF8", + "green" : "0x78", + "red" : "0x53" } }, "idiom" : "universal" diff --git a/Theme/Theme/Assets.xcassets/Colors/AccentXColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/AccentXColor.colorset/Contents.json index 00d59cb46..bf1a96417 100644 --- a/Theme/Theme/Assets.xcassets/Colors/AccentXColor.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/AccentXColor.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.976", - "green" : "0.471", - "red" : "0.329" + "blue" : "0xF8", + "green" : "0x78", + "red" : "0x53" } }, "idiom" : "universal" diff --git a/Theme/Theme/Assets.xcassets/Colors/CardView/CardViewStroke.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/CardView/CardViewStroke.colorset/Contents.json index 0a5fa0807..d31f2bcff 100644 --- a/Theme/Theme/Assets.xcassets/Colors/CardView/CardViewStroke.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/CardView/CardViewStroke.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.878", - "green" : "0.831", - "red" : "0.800" + "blue" : "0xDF", + "green" : "0xD3", + "red" : "0xCC" } }, "idiom" : "universal" diff --git a/Theme/Theme/Assets.xcassets/Colors/CommentCellBackground.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/CommentCellBackground.colorset/Contents.json index 00cf4a827..e9a7a3504 100644 --- a/Theme/Theme/Assets.xcassets/Colors/CommentCellBackground.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/CommentCellBackground.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.961", - "green" : "0.961", - "red" : "0.961" + "blue" : "0xF5", + "green" : "0xF5", + "red" : "0xF5" } }, "idiom" : "universal" diff --git a/Theme/Theme/Assets.xcassets/Colors/CourseCardShadow.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/CourseCardShadow.colorset/Contents.json new file mode 100644 index 000000000..e7c5c162d --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/CourseCardShadow.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.150", + "blue" : "0x2F", + "green" : "0x21", + "red" : "0x19" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "0.400", + "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 aa00c67f5..2daf3d16b 100644 --- a/Theme/Theme/SwiftGen/ThemeAssets.swift +++ b/Theme/Theme/SwiftGen/ThemeAssets.swift @@ -35,6 +35,7 @@ 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 courseCardShadow = ColorAsset(name: "CourseCardShadow") public static let datesSectionBackground = ColorAsset(name: "DatesSectionBackground") public static let datesSectionStroke = ColorAsset(name: "DatesSectionStroke") public static let nextWeekTimelineColor = ColorAsset(name: "NextWeekTimelineColor") diff --git a/Theme/Theme/Theme.swift b/Theme/Theme/Theme.swift index 0e91adb2b..73a4b4939 100644 --- a/Theme/Theme/Theme.swift +++ b/Theme/Theme/Theme.swift @@ -70,6 +70,7 @@ public struct Theme { public private(set) static var slidingStrokeColor = ThemeAssets.slidingStrokeColor.swiftUIColor public private(set) static var primaryHeaderColor = ThemeAssets.primaryHeaderColor.swiftUIColor public private(set) static var secondaryHeaderColor = ThemeAssets.secondaryHeaderColor.swiftUIColor + public private(set) static var courseCardShadow = ThemeAssets.courseCardShadow.swiftUIColor public static func update( accentColor: Color = ThemeAssets.accentColor.swiftUIColor, From a4a85d586da15a4b6db42e47758d3a5c533bbed1 Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Fri, 7 Jun 2024 14:39:57 +0500 Subject: [PATCH 11/55] fix: APIs path and archive fixes --- Core/Core/Network/AuthEndpoint.swift | 4 +- .../Video/PlayerViewControllerHolder.swift | 38 ++++++++-------- .../YoutubePlayerViewControllerHolder.swift | 44 ++++++++++--------- .../Elements/CategoryFilterView.swift | 2 +- .../Elements/PrimaryCardView.swift | 14 +++--- .../Data/Network/ProfileEndpoint.swift | 6 +-- 6 files changed, 57 insertions(+), 51 deletions(-) diff --git a/Core/Core/Network/AuthEndpoint.swift b/Core/Core/Network/AuthEndpoint.swift index e93e5c860..609e4890b 100644 --- a/Core/Core/Network/AuthEndpoint.swift +++ b/Core/Core/Network/AuthEndpoint.swift @@ -29,9 +29,9 @@ enum AuthEndpoint: EndPointType { case .getAuthCookies: return "/oauth2/login/" case .getRegisterFields: - return "user_api/v1/account/registration/" + return "/user_api/v1/account/registration/" case .registerUser: - return "user_api/v1/account/registration/" + return "/user_api/v1/account/registration/" case .validateRegistrationFields: return "/api/user/v1/validation/registration" case .resetPassword: diff --git a/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift b/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift index fac9ef02a..a56e8dfb8 100644 --- a/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift +++ b/Course/Course/Presentation/Video/PlayerViewControllerHolder.swift @@ -178,6 +178,25 @@ public class PlayerViewControllerHolder: PlayerViewControllerHolderProtocol { } } +extension AVPlayerViewController: PlayerControllerProtocol { + public func play() { + player?.play() + } + + public func pause() { + player?.pause() + } + + public func seekTo(to date: Date) { + player?.seek(to: date) + } + + public func stop() { + player?.replaceCurrentItem(with: nil) + } +} + +#if DEBUG extension PlayerViewControllerHolder { static var mock: PlayerViewControllerHolder { PlayerViewControllerHolder( @@ -198,21 +217,4 @@ extension PlayerViewControllerHolder { ) } } - -extension AVPlayerViewController: PlayerControllerProtocol { - public func play() { - player?.play() - } - - public func pause() { - player?.pause() - } - - public func seekTo(to date: Date) { - player?.seek(to: date) - } - - public func stop() { - player?.replaceCurrentItem(with: nil) - } -} +#endif diff --git a/Course/Course/Presentation/Video/YoutubePlayerViewControllerHolder.swift b/Course/Course/Presentation/Video/YoutubePlayerViewControllerHolder.swift index e74545c25..16a4d9eca 100644 --- a/Course/Course/Presentation/Video/YoutubePlayerViewControllerHolder.swift +++ b/Course/Course/Presentation/Video/YoutubePlayerViewControllerHolder.swift @@ -140,27 +140,6 @@ public class YoutubePlayerViewControllerHolder: PlayerViewControllerHolderProtoc } } -extension YoutubePlayerViewControllerHolder { - static var mock: YoutubePlayerViewControllerHolder { - YoutubePlayerViewControllerHolder( - url: URL(string: "")!, - blockID: "", - courseID: "", - selectedCourseTab: 0, - videoResolution: .zero, - pipManager: PipManagerProtocolMock(), - playerTracker: PlayerTrackerProtocolMock(url: URL(string: "")), - playerDelegate: nil, - playerService: PlayerService( - courseID: "", - blockID: "", - interactor: CourseInteractor.mock, - router: CourseRouterMock() - ) - ) - } -} - extension YouTubePlayer: PlayerControllerProtocol { public func play() { self.play(completion: nil) @@ -181,3 +160,26 @@ extension YouTubePlayer: PlayerControllerProtocol { self.stop(completion: nil) } } + +#if DEBUG +extension YoutubePlayerViewControllerHolder { + static var mock: YoutubePlayerViewControllerHolder { + YoutubePlayerViewControllerHolder( + url: URL(string: "")!, + blockID: "", + courseID: "", + selectedCourseTab: 0, + videoResolution: .zero, + pipManager: PipManagerProtocolMock(), + playerTracker: PlayerTrackerProtocolMock(url: URL(string: "")), + playerDelegate: nil, + playerService: PlayerService( + courseID: "", + blockID: "", + interactor: CourseInteractor.mock, + router: CourseRouterMock() + ) + ) + } +} +#endif diff --git a/Dashboard/Dashboard/Presentation/Elements/CategoryFilterView.swift b/Dashboard/Dashboard/Presentation/Elements/CategoryFilterView.swift index c01444840..a0b24625b 100644 --- a/Dashboard/Dashboard/Presentation/Elements/CategoryFilterView.swift +++ b/Dashboard/Dashboard/Presentation/Elements/CategoryFilterView.swift @@ -58,7 +58,7 @@ struct CategoryFilterView: View { Text(option.text) .font(Theme.Fonts.titleSmall) .foregroundColor( - option == selectedOption ? Theme.Colors.white : ( + option == selectedOption ? Theme.Colors.slidingSelectedTextColor : ( colorScheme == .light ? Theme.Colors.accentColor : .white ) ) diff --git a/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift b/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift index 8428c1857..27e2abfb5 100644 --- a/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift +++ b/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift @@ -179,9 +179,7 @@ public struct PrimaryCardView: View { .renderingMode(.template) .resizable() .frame(width: 24, height: 24) - .foregroundStyle( - selected ? Theme.Colors.white : Theme.Colors.textPrimary - ) + .foregroundStyle(foregroundColor(selected)) .padding(12) VStack(alignment: .leading, spacing: 6) { @@ -190,20 +188,20 @@ public struct PrimaryCardView: View { .font(Theme.Fonts.labelSmall) .multilineTextAlignment(.leading) .lineLimit(1) - .foregroundStyle(selected ? Theme.Colors.white : Theme.Colors.textPrimary) + .foregroundStyle(foregroundColor(selected)) } Text(title) .font(Theme.Fonts.titleSmall) .multilineTextAlignment(.leading) .lineLimit(1) - .foregroundStyle(selected ? Theme.Colors.white : Theme.Colors.textPrimary) + .foregroundStyle(foregroundColor(selected)) } .padding(.top, 2) } } Spacer() CoreAssets.chevronRight.swiftUIImage - .foregroundStyle(selected ? Theme.Colors.white : Theme.Colors.textPrimary) + .foregroundStyle(foregroundColor(selected)) .padding(8) } .padding(.top, 8) @@ -212,6 +210,10 @@ public struct PrimaryCardView: View { }) } + private func foregroundColor(_ selected: Bool) -> SwiftUI.Color { + return selected ? Theme.Colors.primaryButtonTextColor : Theme.Colors.textPrimary + } + private var courseBanner: some View { return KFImage(URL(string: courseImage)) .onFailureImage(CoreAssets.noCourseImage.image) diff --git a/Profile/Profile/Data/Network/ProfileEndpoint.swift b/Profile/Profile/Data/Network/ProfileEndpoint.swift index bf3b330ca..b17e5db6d 100644 --- a/Profile/Profile/Data/Network/ProfileEndpoint.swift +++ b/Profile/Profile/Data/Network/ProfileEndpoint.swift @@ -20,11 +20,11 @@ enum ProfileEndpoint: EndPointType { var path: String { switch self { case .getUserProfile(let username): - return "api/user/v1/accounts/\(username)" + return "/api/user/v1/accounts/\(username)" case .logOut: - return "oauth2/revoke_token/" + return "/oauth2/revoke_token/" case let .updateUserProfile(username, _): - return "api/user/v1/accounts/\(username)" + return "/api/user/v1/accounts/\(username)" case let .uploadProfilePicture(username, _): return "/api/user/v1/accounts/\(username)/image" case .deleteProfilePicture(username: let username): From d3805511fdcfaafee587a10a06d2e5bd189c7f53 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Fri, 7 Jun 2024 15:18:48 +0300 Subject: [PATCH 12/55] feat: [FC-0047] Course progress and collapsing sections (#446) * feat: course home navigation * fix: address feedback * fix: merge conflicts * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback --- Core/Core.xcodeproj/project.pbxproj | 3 - .../deleteDownloading.imageset/Contents.json | 2 +- .../deleteDownloading.imageset/Frame-17.svg | 13 - .../deleteDownloading.svg | 3 + .../startDownloading.imageset/Contents.json | 15 +- .../startDownloading.imageset/Frame-16.svg | 12 - .../download_dark.svg | 3 + .../download_light.svg | 3 + .../finished_sequence.imageset/Contents.json | 12 + .../finished_sequence.svg | 3 + .../Config/UIComponentsConfig.swift | 6 +- .../Data/Model/Data_PrimaryEnrollment.swift | 2 +- Core/Core/Domain/Model/CourseBlockModel.swift | 38 +- Core/Core/SwiftGen/Assets.swift | 1 + .../View/Base/CustomDisclosureGroup.swift | 49 --- Core/Core/View/Base/DownloadView.swift | 5 +- Core/Core/en.lproj/Localizable.strings | 1 + Course/Course.xcodeproj/project.pbxproj | 38 +- Course/Course/Data/CourseRepository.swift | 30 +- .../Model/Data_CourseOutlineResponse.swift | 37 +- .../Course/Data/Network/CourseEndpoint.swift | 2 +- .../CourseCoreModel.xcdatamodel/contents | 8 +- Course/Course/Domain/CourseInteractor.swift | 10 +- .../Container/CourseContainerViewModel.swift | 19 +- .../Outline/ContinueWithView.swift | 2 + .../Outline/CourseOutlineView.swift | 38 +- .../CourseStructureNestedListView.swift | 237 ----------- .../CourseStructure/CourseStructureView.swift | 134 ------ .../CourseVerticalImageView.swift | 7 +- .../CourseVertical/CourseVerticalView.swift | 9 +- .../Subviews/CourseProgressView.swift | 54 +++ .../Subviews/CustomDisclosureGroup.swift | 400 ++++++++++++++++++ .../Presentation/Unit/CourseUnitView.swift | 23 +- .../DropdownList/CourseUnitDropDownCell.swift | 1 + .../DropdownList/CourseUnitDropDownList.swift | 4 + .../CourseUnitVerticalsDropdownView.swift | 4 + Course/Course/SwiftGen/Strings.swift | 18 + Course/Course/en.lproj/Localizable.strings | 5 + .../Course/en.lproj/Localizable.stringsdict | 42 ++ Course/Course/uk.lproj/Localizable.strings | 5 + .../Course/uk.lproj/Localizable.stringsdict | 42 ++ .../CourseContainerViewModelTests.swift | 70 ++- .../Unit/CourseDateViewModelTests.swift | 3 +- .../Unit/CourseUnitViewModelTests.swift | 16 +- OpenEdX/DI/AppAssembly.swift | 5 +- OpenEdX/DI/ScreenAssembly.swift | 2 +- OpenEdX/Data/CoursePersistence.swift | 30 +- OpenEdX/Managers/PipManager.swift | 8 +- OpenEdX/Router.swift | 9 +- .../CardViewBackground.colorset/Contents.json | 6 +- .../InfoColor.colorset copy/Contents.json | 38 -- .../Contents.json | 38 -- .../Colors/TabbarColor.colorset/Contents.json | 6 +- 53 files changed, 941 insertions(+), 630 deletions(-) delete mode 100644 Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/Frame-17.svg create mode 100644 Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/deleteDownloading.svg delete mode 100644 Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/Frame-16.svg create mode 100644 Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/download_dark.svg create mode 100644 Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/download_light.svg create mode 100644 Core/Core/Assets.xcassets/finished_sequence.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/finished_sequence.imageset/finished_sequence.svg delete mode 100644 Core/Core/View/Base/CustomDisclosureGroup.swift delete mode 100644 Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift delete mode 100644 Course/Course/Presentation/Outline/CourseStructure/CourseStructureView.swift create mode 100644 Course/Course/Presentation/Subviews/CourseProgressView.swift create mode 100644 Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift create mode 100644 Course/Course/en.lproj/Localizable.stringsdict create mode 100644 Course/Course/uk.lproj/Localizable.stringsdict delete mode 100644 Theme/Theme/Assets.xcassets/Colors/InfoColor.colorset copy/Contents.json delete mode 100644 Theme/Theme/Assets.xcassets/Colors/IrreversibleAlert.colorset copy/Contents.json diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 3d8d938c7..ad4325f39 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -163,7 +163,6 @@ BA8FA6702AD59EA300EA029A /* MicrosoftAuthProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA8FA66F2AD59EA300EA029A /* MicrosoftAuthProvider.swift */; }; BA981BCE2B8F5C49005707C2 /* Sequence+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA981BCD2B8F5C49005707C2 /* Sequence+Extensions.swift */; }; BA981BD02B91ED50005707C2 /* FullScreenProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA981BCF2B91ED50005707C2 /* FullScreenProgressView.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 */; }; BAD9CA422B2B140100DE790A /* AgreementConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */; }; @@ -359,7 +358,6 @@ BA8FA66F2AD59EA300EA029A /* MicrosoftAuthProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrosoftAuthProvider.swift; sourceTree = ""; }; BA981BCD2B8F5C49005707C2 /* Sequence+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sequence+Extensions.swift"; sourceTree = ""; }; BA981BCF2B91ED50005707C2 /* FullScreenProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenProgressView.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 = ""; }; BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementConfigTests.swift; sourceTree = ""; }; @@ -748,7 +746,6 @@ 023A1137291432FD00D0D354 /* FieldConfiguration.swift */, BA4AFB412B5A7A0900A21367 /* VideoDownloadQualityView.swift */, BA593F1A2AF8E487009ADB51 /* ScrollSlidingTabBar */, - BAAD62C52AFCF00B000E6103 /* CustomDisclosureGroup.swift */, BA8FA6672AD59A5700EA029A /* SocialAuthButton.swift */, 02E93F862AEBAED4006C4750 /* AppReview */, BA981BCF2B91ED50005707C2 /* FullScreenProgressView.swift */, diff --git a/Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/Contents.json b/Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/Contents.json index d1927cffc..41ea480fe 100644 --- a/Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/Contents.json +++ b/Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/Contents.json @@ -1,7 +1,7 @@ { "images" : [ { - "filename" : "Frame-17.svg", + "filename" : "deleteDownloading.svg", "idiom" : "universal" } ], diff --git a/Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/Frame-17.svg b/Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/Frame-17.svg deleted file mode 100644 index 0ae948676..000000000 --- a/Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/Frame-17.svg +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - - - - diff --git a/Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/deleteDownloading.svg b/Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/deleteDownloading.svg new file mode 100644 index 000000000..ed2659aab --- /dev/null +++ b/Core/Core/Assets.xcassets/DownloadManager/deleteDownloading.imageset/deleteDownloading.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/Contents.json b/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/Contents.json index 866687bad..672c958c5 100644 --- a/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/Contents.json +++ b/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/Contents.json @@ -1,12 +1,25 @@ { "images" : [ { - "filename" : "Frame-16.svg", + "filename" : "download_light.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "download_dark.svg", "idiom" : "universal" } ], "info" : { "author" : "xcode", "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" } } diff --git a/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/Frame-16.svg b/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/Frame-16.svg deleted file mode 100644 index 24d291489..000000000 --- a/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/Frame-16.svg +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - diff --git a/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/download_dark.svg b/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/download_dark.svg new file mode 100644 index 000000000..8a29b74a2 --- /dev/null +++ b/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/download_dark.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/download_light.svg b/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/download_light.svg new file mode 100644 index 000000000..1f933d639 --- /dev/null +++ b/Core/Core/Assets.xcassets/DownloadManager/startDownloading.imageset/download_light.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/finished_sequence.imageset/Contents.json b/Core/Core/Assets.xcassets/finished_sequence.imageset/Contents.json new file mode 100644 index 000000000..9d0a51bf3 --- /dev/null +++ b/Core/Core/Assets.xcassets/finished_sequence.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "finished_sequence.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Core/Core/Assets.xcassets/finished_sequence.imageset/finished_sequence.svg b/Core/Core/Assets.xcassets/finished_sequence.imageset/finished_sequence.svg new file mode 100644 index 000000000..51ed61934 --- /dev/null +++ b/Core/Core/Assets.xcassets/finished_sequence.imageset/finished_sequence.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Configuration/Config/UIComponentsConfig.swift b/Core/Core/Configuration/Config/UIComponentsConfig.swift index cb5ce3e68..1b8cf788e 100644 --- a/Core/Core/Configuration/Config/UIComponentsConfig.swift +++ b/Core/Core/Configuration/Config/UIComponentsConfig.swift @@ -8,16 +8,16 @@ import Foundation private enum Keys: String, RawStringExtractable { - case courseNestedListEnabled = "COURSE_NESTED_LIST_ENABLED" + case courseDropDownNavigationEnabled = "COURSE_DROPDOWN_NAVIGATION_ENABLED" case courseUnitProgressEnabled = "COURSE_UNIT_PROGRESS_ENABLED" } public class UIComponentsConfig: NSObject { - public var courseNestedListEnabled: Bool + public var courseDropDownNavigationEnabled: Bool public var courseUnitProgressEnabled: Bool init(dictionary: [String: Any]) { - courseNestedListEnabled = dictionary[Keys.courseNestedListEnabled] as? Bool ?? false + courseDropDownNavigationEnabled = dictionary[Keys.courseDropDownNavigationEnabled] as? Bool ?? false courseUnitProgressEnabled = dictionary[Keys.courseUnitProgressEnabled] as? Bool ?? false super.init() } diff --git a/Core/Core/Data/Model/Data_PrimaryEnrollment.swift b/Core/Core/Data/Model/Data_PrimaryEnrollment.swift index 1102bae78..60764c78a 100644 --- a/Core/Core/Data/Model/Data_PrimaryEnrollment.swift +++ b/Core/Core/Data/Model/Data_PrimaryEnrollment.swift @@ -36,7 +36,7 @@ public extension DataLayer { public let certificate: DataLayer.Certificate? public let courseModes: [CourseMode]? public let courseStatus: CourseStatus? - public let progress: CourseProgress? + public let progress: DataLayer.CourseProgress? public let courseAssignments: CourseAssignments? enum CodingKeys: String, CodingKey { diff --git a/Core/Core/Domain/Model/CourseBlockModel.swift b/Core/Core/Domain/Model/CourseBlockModel.swift index 406bea3ed..96ef3ccde 100644 --- a/Core/Core/Domain/Model/CourseBlockModel.swift +++ b/Core/Core/Domain/Model/CourseBlockModel.swift @@ -24,6 +24,7 @@ public struct CourseStructure: Equatable { public let certificate: Certificate? public let org: String public let isSelfPaced: Bool + public let courseProgress: CourseProgress? public init( id: String, @@ -37,7 +38,8 @@ public struct CourseStructure: Equatable { media: DataLayer.CourseMedia, certificate: Certificate?, org: String, - isSelfPaced: Bool + isSelfPaced: Bool, + courseProgress: CourseProgress? ) { self.id = id self.graded = graded @@ -51,6 +53,7 @@ public struct CourseStructure: Equatable { self.certificate = certificate self.org = org self.isSelfPaced = isSelfPaced + self.courseProgress = courseProgress } public func totalVideosSizeInBytes(downloadQuality: DownloadQuality) -> Int { @@ -78,6 +81,16 @@ public struct CourseStructure: Equatable { } } +public struct CourseProgress { + public let totalAssignmentsCount: Int? + public let assignmentsCompleted: Int? + + public init(totalAssignmentsCount: Int, assignmentsCompleted: Int) { + self.totalAssignmentsCount = totalAssignmentsCount + self.assignmentsCompleted = assignmentsCompleted + } +} + public struct CourseChapter: Identifiable { public let blockId: String @@ -109,6 +122,8 @@ public struct CourseSequential: Identifiable { public let type: BlockType public let completion: Double public var childs: [CourseVertical] + public let sequentialProgress: SequentialProgress? + public let due: Date? public var isDownloadable: Bool { return childs.first(where: { $0.isDownloadable }) != nil @@ -120,7 +135,9 @@ public struct CourseSequential: Identifiable { displayName: String, type: BlockType, completion: Double, - childs: [CourseVertical] + childs: [CourseVertical], + sequentialProgress: SequentialProgress?, + due: Date? ) { self.blockId = blockId self.id = id @@ -128,6 +145,8 @@ public struct CourseSequential: Identifiable { self.type = type self.completion = completion self.childs = childs + self.sequentialProgress = sequentialProgress + self.due = due } } @@ -177,6 +196,18 @@ public struct SubtitleUrl: Equatable { } } +public struct SequentialProgress { + public let assignmentType: String? + public let numPointsEarned: Int? + public let numPointsPossible: Int? + + public init(assignmentType: String?, numPointsEarned: Int?, numPointsPossible: Int?) { + self.assignmentType = assignmentType + self.numPointsEarned = numPointsEarned + self.numPointsPossible = numPointsPossible + } +} + public struct CourseBlock: Hashable, Identifiable { public static func == (lhs: CourseBlock, rhs: CourseBlock) -> Bool { lhs.id == rhs.id && @@ -193,6 +224,7 @@ public struct CourseBlock: Hashable, Identifiable { public let courseId: String public let topicId: String? public let graded: Bool + public let due: Date? public var completion: Double public let type: BlockType public let displayName: String @@ -212,6 +244,7 @@ public struct CourseBlock: Hashable, Identifiable { courseId: String, topicId: String? = nil, graded: Bool, + due: Date?, completion: Double, type: BlockType, displayName: String, @@ -226,6 +259,7 @@ public struct CourseBlock: Hashable, Identifiable { self.courseId = courseId self.topicId = topicId self.graded = graded + self.due = due self.completion = completion self.type = type self.displayName = displayName diff --git a/Core/Core/SwiftGen/Assets.swift b/Core/Core/SwiftGen/Assets.swift index ea47e8ee4..6f38ed569 100644 --- a/Core/Core/SwiftGen/Assets.swift +++ b/Core/Core/SwiftGen/Assets.swift @@ -107,6 +107,7 @@ public enum CoreAssets { public static let clearInput = ImageAsset(name: "clearInput") public static let edit = ImageAsset(name: "edit") public static let favorite = ImageAsset(name: "favorite") + public static let finishedSequence = ImageAsset(name: "finished_sequence") public static let goodWork = ImageAsset(name: "goodWork") public static let learnEmpty = ImageAsset(name: "learn_empty") public static let airmail = ImageAsset(name: "airmail") diff --git a/Core/Core/View/Base/CustomDisclosureGroup.swift b/Core/Core/View/Base/CustomDisclosureGroup.swift deleted file mode 100644 index c4a023ed3..000000000 --- a/Core/Core/View/Base/CustomDisclosureGroup.swift +++ /dev/null @@ -1,49 +0,0 @@ -// -// CustomDisclosureGroup.swift -// Core -// -// Created by Eugene Yatsenko on 09.11.2023. -// - -import SwiftUI - -public struct CustomDisclosureGroup: View { - - @Binding var isExpanded: Bool - - private var onClick: () -> Void - private var animation: Animation? - private let header: Header - private let content: Content - - public init( - animation: Animation?, - isExpanded: Binding, - onClick: @escaping () -> Void, - header: (_ isExpanded: Bool) -> Header, - content: () -> Content - ) { - self.onClick = onClick - self._isExpanded = isExpanded - self.animation = animation - self.header = header(isExpanded.wrappedValue) - self.content = content() - } - - public var body: some View { - VStack(spacing: 0) { - Button { - withAnimation(animation) { - onClick() - } - } label: { - header - .contentShape(Rectangle()) - } - if isExpanded { - content - } - } - .clipped() - } -} diff --git a/Core/Core/View/Base/DownloadView.swift b/Core/Core/View/Base/DownloadView.swift index 37f63e41d..ede7c2912 100644 --- a/Core/Core/View/Base/DownloadView.swift +++ b/Core/Core/View/Base/DownloadView.swift @@ -24,7 +24,6 @@ public struct DownloadAvailableView: View { .resizable() .scaledToFit() .frame(width: 24, height: 24) - .foregroundColor(Theme.Colors.textPrimary) } .frame(width: 30, height: 30) } @@ -41,6 +40,7 @@ public struct DownloadProgressView: View { .resizable() .scaledToFit() .frame(width: 20, height: 20) + .foregroundStyle(Theme.Colors.snackbarErrorColor) .foregroundColor(Theme.Colors.textPrimary) } } @@ -52,11 +52,10 @@ public struct DownloadFinishedView: View { public var body: some View { VStack(spacing: 0) { - CoreAssets.deleteDownloading.swiftUIImage.renderingMode(.template) + CoreAssets.deleteDownloading.swiftUIImage .resizable() .scaledToFit() .frame(width: 24, height: 24) - .foregroundColor(Theme.Colors.textPrimary) } .frame(width: 30, height: 30) } diff --git a/Core/Core/en.lproj/Localizable.strings b/Core/Core/en.lproj/Localizable.strings index 44186571a..b1fda17c5 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -48,6 +48,7 @@ "DATE.COURSE_STARTS" = "Course Starts"; "DATE.COURSE_ENDS" = "Course Ends"; "DATE.COURSE_ENDED" = "Course Ended"; + "DATE.ENDED" = "Ended"; "DATE.START" = "Start"; "DATE.STARTED" = "Started"; diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index ed692cdd8..efc45a860 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -45,7 +45,10 @@ 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 */; }; + 02BB20182BFCE7B200364948 /* CustomDisclosureGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02BB20172BFCE7B200364948 /* CustomDisclosureGroup.swift */; }; + 02C355392C08DCD700501342 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 02C355372C08DCD700501342 /* Localizable.stringsdict */; }; 02D4FC2E2BBD7C9C00C47748 /* MessageSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D4FC2D2BBD7C9C00C47748 /* MessageSectionView.swift */; }; + 02E3803E2BFF9F0A00815AFA /* CourseProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E3803D2BFF9F0A00815AFA /* CourseProgressView.swift */; }; 02F0144F28F46474002E513D /* CourseContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F0144E28F46474002E513D /* CourseContainerView.swift */; }; 02F0145728F4A2FF002E513D /* CourseContainerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F0145628F4A2FF002E513D /* CourseContainerViewModel.swift */; }; 02F3BFDD29252E900051930C /* CourseRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F3BFDC29252E900051930C /* CourseRouter.swift */; }; @@ -84,12 +87,10 @@ BA58CF5D2B3D804D005B102E /* CourseStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA58CF5C2B3D804D005B102E /* CourseStorage.swift */; }; BA58CF612B471041005B102E /* VideoDownloadQualityBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA58CF602B471041005B102E /* VideoDownloadQualityBarView.swift */; }; BA58CF642B471363005B102E /* VideoDownloadQualityContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA58CF632B471363005B102E /* VideoDownloadQualityContainerView.swift */; }; - BAAD62C82AFD00EE000E6103 /* CourseStructureNestedListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAAD62C72AFD00EE000E6103 /* CourseStructureNestedListView.swift */; }; BAC0E0D82B32EF03006B68A9 /* DownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC0E0D72B32EF03006B68A9 /* DownloadsView.swift */; }; BAC0E0DB2B32F0AE006B68A9 /* CourseVideoDownloadBarViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC0E0DA2B32F0AE006B68A9 /* CourseVideoDownloadBarViewModel.swift */; }; BAC0E0DE2B32F0F3006B68A9 /* DownloadsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC0E0DD2B32F0F3006B68A9 /* DownloadsViewModel.swift */; }; 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 */; }; DB7D6EAC2ADFCAC50036BB13 /* CourseDatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EAB2ADFCAC40036BB13 /* CourseDatesView.swift */; }; @@ -149,7 +150,11 @@ 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; }; + 02BB20172BFCE7B200364948 /* CustomDisclosureGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDisclosureGroup.swift; sourceTree = ""; }; + 02C355382C08DCD700501342 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = ""; }; + 02C3553A2C08DCE000501342 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = uk; path = uk.lproj/Localizable.stringsdict; sourceTree = ""; }; 02D4FC2D2BBD7C9C00C47748 /* MessageSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSectionView.swift; sourceTree = ""; }; + 02E3803D2BFF9F0A00815AFA /* CourseProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseProgressView.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 = ""; }; @@ -203,12 +208,10 @@ BA58CF5C2B3D804D005B102E /* CourseStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseStorage.swift; sourceTree = ""; }; BA58CF602B471041005B102E /* VideoDownloadQualityBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadQualityBarView.swift; sourceTree = ""; }; BA58CF632B471363005B102E /* VideoDownloadQualityContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadQualityContainerView.swift; sourceTree = ""; }; - BAAD62C72AFD00EE000E6103 /* CourseStructureNestedListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseStructureNestedListView.swift; sourceTree = ""; }; BAC0E0D72B32EF03006B68A9 /* DownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsView.swift; sourceTree = ""; }; BAC0E0DA2B32F0AE006B68A9 /* CourseVideoDownloadBarViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseVideoDownloadBarViewModel.swift; sourceTree = ""; }; BAC0E0DD2B32F0F3006B68A9 /* DownloadsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadsViewModel.swift; sourceTree = ""; }; 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 = ""; }; DB7D6EAB2ADFCAC40036BB13 /* CourseDatesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CourseDatesView.swift; sourceTree = ""; }; @@ -313,6 +316,7 @@ 97EA4D822B84EFA900663F58 /* Managers */, 97CA95212B875EA200A9EDEA /* Views */, 02B6B3B428E1C49400232911 /* Localizable.strings */, + 02C355372C08DCD700501342 /* Localizable.stringsdict */, ); path = Course; sourceTree = ""; @@ -440,7 +444,6 @@ isa = PBXGroup; children = ( BAD9CA462B2C888600DE790A /* CourseVertical */, - BAD9CA472B2C88AA00DE790A /* CourseStructure */, 02635AC62A24F181008062F2 /* ContinueWithView.swift */, 0270210128E736E700F54332 /* CourseOutlineView.swift */, ); @@ -580,15 +583,6 @@ path = CourseVertical; sourceTree = ""; }; - BAD9CA472B2C88AA00DE790A /* CourseStructure */ = { - isa = PBXGroup; - children = ( - BAD9CA432B2C87A200DE790A /* CourseStructureView.swift */, - BAAD62C72AFD00EE000E6103 /* CourseStructureNestedListView.swift */, - ); - path = CourseStructure; - sourceTree = ""; - }; BAD9CA482B2C88D500DE790A /* Subviews */ = { isa = PBXGroup; children = ( @@ -596,6 +590,8 @@ BAC0E0D92B32F0A2006B68A9 /* CourseVideoDownloadBarView */, BA58CF622B471047005B102E /* VideoDownloadQualityBarView */, 02FCB2B22BBEB36600373180 /* CourseHeaderView.swift */, + 02BB20172BFCE7B200364948 /* CustomDisclosureGroup.swift */, + 02E3803D2BFF9F0A00815AFA /* CourseProgressView.swift */, ); path = Subviews; sourceTree = ""; @@ -746,6 +742,7 @@ files = ( 0289F90228E1C3E10064F8F3 /* swiftgen.yml in Resources */, 02B6B3B228E1C49400232911 /* Localizable.strings in Resources */, + 02C355392C08DCD700501342 /* Localizable.stringsdict in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -885,13 +882,11 @@ 068DDA5F2B1E198700FF8CCB /* CourseUnitDropDownList.swift in Sources */, 022F8E182A1E2642008EFAB9 /* EncodedVideoPlayerViewModel.swift in Sources */, 0248C92729C097EB00DC8402 /* CourseVerticalViewModel.swift in Sources */, - BAD9CA442B2C87A200DE790A /* CourseStructureView.swift in Sources */, 02F0145728F4A2FF002E513D /* CourseContainerViewModel.swift in Sources */, 02B6B3B728E1D11E00232911 /* CourseInteractor.swift in Sources */, 073512E229C0E400005CFA41 /* BaseCourseViewModel.swift in Sources */, 0231124F28EDA811002588FB /* CourseUnitViewModel.swift in Sources */, 02F0144F28F46474002E513D /* CourseContainerView.swift in Sources */, - BAAD62C82AFD00EE000E6103 /* CourseStructureNestedListView.swift in Sources */, 02A8076829474831007F53AB /* CourseVerticalView.swift in Sources */, 97E7DF0F2B7C852A00A2A09B /* DatesStatusInfoView.swift in Sources */, 067B7B4F2BED339200D1768F /* PlayerDelegateProtocol.swift in Sources */, @@ -907,6 +902,7 @@ DB7D6EAE2ADFCB4A0036BB13 /* CourseDatesViewModel.swift in Sources */, 067B7B522BED339200D1768F /* SubtitlesView.swift in Sources */, 07DE59862BECB868001CBFBC /* CourseAnalytics.swift in Sources */, + 02BB20182BFCE7B200364948 /* CustomDisclosureGroup.swift in Sources */, 022C64E229ADEB83000F532B /* CourseUpdate.swift in Sources */, BA58CF642B471363005B102E /* VideoDownloadQualityContainerView.swift in Sources */, BA58CF5D2B3D804D005B102E /* CourseStorage.swift in Sources */, @@ -924,6 +920,7 @@ 02FCB2B32BBEB36600373180 /* CourseHeaderView.swift in Sources */, DB7D6EAC2ADFCAC50036BB13 /* CourseDatesView.swift in Sources */, 0766DFCC299AA7A600EBEF6A /* YouTubeVideoPlayer.swift in Sources */, + 02E3803E2BFF9F0A00815AFA /* CourseProgressView.swift in Sources */, 022F8E162A1DFBC6008EFAB9 /* YouTubeVideoPlayerViewModel.swift in Sources */, 02B6B3BE28E1D15C00232911 /* CourseEndpoint.swift in Sources */, 97EA4D862B85034D00663F58 /* CalendarManager.swift in Sources */, @@ -951,6 +948,15 @@ name = Localizable.strings; sourceTree = ""; }; + 02C355372C08DCD700501342 /* Localizable.stringsdict */ = { + isa = PBXVariantGroup; + children = ( + 02C355382C08DCD700501342 /* en */, + 02C3553A2C08DCE000501342 /* uk */, + ); + name = Localizable.stringsdict; + sourceTree = ""; + }; /* End PBXVariantGroup section */ /* Begin XCBuildConfiguration section */ diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index 19073d2de..612273fe5 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -139,7 +139,11 @@ public class CourseRepository: CourseRepositoryProtocol { media: course.media, certificate: course.certificate?.domain, org: course.org ?? "", - isSelfPaced: course.isSelfPaced + isSelfPaced: course.isSelfPaced, + courseProgress: course.courseProgress == nil ? nil : CourseProgress( + totalAssignmentsCount: course.courseProgress?.totalAssignmentsCount ?? 0, + assignmentsCompleted: course.courseProgress?.assignmentsCompleted ?? 0 + ) ) } @@ -173,7 +177,13 @@ public class CourseRepository: CourseRepositoryProtocol { displayName: sequential.displayName, type: BlockType(rawValue: sequential.type) ?? .unknown, completion: sequential.completion ?? 0, - childs: childs + childs: childs, + sequentialProgress: SequentialProgress( + assignmentType: sequential.assignmentProgress?.assignmentType, + numPointsEarned: Int(sequential.assignmentProgress?.numPointsEarned ?? 0), + numPointsPossible: Int(sequential.assignmentProgress?.numPointsPossible ?? 0) + ), + due: sequential.due == nil ? nil : Date(iso8601: sequential.due!) ) } @@ -211,6 +221,7 @@ public class CourseRepository: CourseRepositoryProtocol { courseId: courseId, topicId: block.userViewData?.topicID, graded: block.graded, + due: block.due == nil ? nil : Date(iso8601: block.due!), completion: block.completion ?? 0, type: BlockType(rawValue: block.type) ?? .unknown, displayName: block.displayName, @@ -350,7 +361,11 @@ And there are various ways of describing it-- call it oral poetry or media: course.media, certificate: course.certificate?.domain, org: course.org ?? "", - isSelfPaced: course.isSelfPaced + isSelfPaced: course.isSelfPaced, + courseProgress: course.courseProgress == nil ? nil : CourseProgress( + totalAssignmentsCount: course.courseProgress?.totalAssignmentsCount ?? 0, + assignmentsCompleted: course.courseProgress?.assignmentsCompleted ?? 0 + ) ) } @@ -385,7 +400,13 @@ And there are various ways of describing it-- call it oral poetry or displayName: sequential.displayName, type: BlockType(rawValue: sequential.type) ?? .unknown, completion: sequential.completion ?? 0, - childs: childs + childs: childs, + sequentialProgress: SequentialProgress( + assignmentType: sequential.assignmentProgress?.assignmentType, + numPointsEarned: Int(sequential.assignmentProgress?.numPointsEarned ?? 0), + numPointsPossible: Int(sequential.assignmentProgress?.numPointsPossible ?? 0) + ), + due: sequential.due == nil ? nil : Date(iso8601: sequential.due!) ) } @@ -421,6 +442,7 @@ And there are various ways of describing it-- call it oral poetry or courseId: courseId, topicId: block.userViewData?.topicID, graded: block.graded, + due: block.due == nil ? nil : Date(iso8601: block.due!), completion: block.completion ?? 0, type: BlockType(rawValue: block.type) ?? .unknown, displayName: block.displayName, diff --git a/Course/Course/Data/Model/Data_CourseOutlineResponse.swift b/Course/Course/Data/Model/Data_CourseOutlineResponse.swift index f2a060e13..5cee8c3e0 100644 --- a/Course/Course/Data/Model/Data_CourseOutlineResponse.swift +++ b/Course/Course/Data/Model/Data_CourseOutlineResponse.swift @@ -21,6 +21,7 @@ public extension DataLayer { public let certificate: Certificate? public let org: String? public let isSelfPaced: Bool + public let courseProgress: CourseProgress? enum CodingKeys: String, CodingKey { case blocks @@ -30,6 +31,7 @@ public extension DataLayer { case certificate case org case isSelfPaced = "is_self_paced" + case courseProgress = "course_progress" } public init( @@ -39,7 +41,8 @@ public extension DataLayer { media: DataLayer.CourseMedia, certificate: Certificate?, org: String?, - isSelfPaced: Bool + isSelfPaced: Bool, + courseProgress: CourseProgress? ) { self.rootItem = rootItem self.dict = dict @@ -48,6 +51,7 @@ public extension DataLayer { self.certificate = certificate self.org = org self.isSelfPaced = isSelfPaced + self.courseProgress = courseProgress } public init(from decoder: Decoder) throws { @@ -60,6 +64,7 @@ public extension DataLayer { certificate = try values.decode(Certificate.self, forKey: .certificate) org = try values.decode(String.self, forKey: .org) isSelfPaced = try values.decode(Bool.self, forKey: .isSelfPaced) + courseProgress = try? values.decode(DataLayer.CourseProgress.self, forKey: .courseProgress) } } } @@ -68,6 +73,7 @@ public extension DataLayer { public let blockId: String public let id: String public let graded: Bool + public let due: String? public let completion: Double? public let studentUrl: String public let webUrl: String @@ -77,11 +83,13 @@ public extension DataLayer { public let allSources: [String]? public let userViewData: CourseDetailUserViewData? public let multiDevice: Bool? + public let assignmentProgress: AssignmentProgress? public init( blockId: String, id: String, graded: Bool, + due: String?, completion: Double?, studentUrl: String, webUrl: String, @@ -90,11 +98,13 @@ public extension DataLayer { descendants: [String]?, allSources: [String]?, userViewData: CourseDetailUserViewData?, - multiDevice: Bool? + multiDevice: Bool?, + assignmentProgress: AssignmentProgress? ) { self.blockId = blockId self.id = id self.graded = graded + self.due = due self.completion = completion self.studentUrl = studentUrl self.webUrl = webUrl @@ -104,10 +114,11 @@ public extension DataLayer { self.allSources = allSources self.userViewData = userViewData self.multiDevice = multiDevice + self.assignmentProgress = assignmentProgress } public enum CodingKeys: String, CodingKey { - case id, type, descendants, graded, completion + case id, type, descendants, graded, completion, due case blockId = "block_id" case studentUrl = "student_view_url" case webUrl = "lms_web_url" @@ -115,9 +126,28 @@ public extension DataLayer { case userViewData = "student_view_data" case allSources = "all_sources" case multiDevice = "student_view_multi_device" + case assignmentProgress = "assignment_progress" } } + + struct AssignmentProgress: Codable { + public let assignmentType: String? + public let numPointsEarned: Double? + public let numPointsPossible: Double? + + public enum CodingKeys: String, CodingKey { + case assignmentType = "assignment_type" + case numPointsEarned = "num_points_earned" + case numPointsPossible = "num_points_possible" + } + public init(assignmentType: String?, numPointsEarned: Double?, numPointsPossible: Double?) { + self.assignmentType = assignmentType + self.numPointsEarned = numPointsEarned + self.numPointsPossible = numPointsPossible + } + } + struct Transcripts: Codable { public let en: String? @@ -202,6 +232,5 @@ public extension DataLayer { case fileSize = "file_size" case streamPriority = "stream_priority" } - } } diff --git a/Course/Course/Data/Network/CourseEndpoint.swift b/Course/Course/Data/Network/CourseEndpoint.swift index 2abafa14a..6ce7a048a 100644 --- a/Course/Course/Data/Network/CourseEndpoint.swift +++ b/Course/Course/Data/Network/CourseEndpoint.swift @@ -86,7 +86,7 @@ enum CourseEndpoint: EndPointType { "nav_depth": "4", "requested_fields": """ contains_gated_content,show_gated_sections,special_exam_info,graded, - format,student_view_multi_device,due,completion + format,student_view_multi_device,due,completion,assignment_progress """, "block_counts": "video" ] diff --git a/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents b/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents index d25cbc5ad..d8e99bd3e 100644 --- a/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents +++ b/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents @@ -2,14 +2,18 @@ + + + + @@ -77,6 +81,7 @@ + @@ -85,6 +90,7 @@ + @@ -101,4 +107,4 @@ - \ No newline at end of file + diff --git a/Course/Course/Domain/CourseInteractor.swift b/Course/Course/Domain/CourseInteractor.swift index dfec16d70..f76d5ed2e 100644 --- a/Course/Course/Domain/CourseInteractor.swift +++ b/Course/Course/Domain/CourseInteractor.swift @@ -55,7 +55,11 @@ public class CourseInteractor: CourseInteractorProtocol { media: course.media, certificate: course.certificate, org: course.org, - isSelfPaced: course.isSelfPaced + isSelfPaced: course.isSelfPaced, + courseProgress: course.courseProgress == nil ? nil : CourseProgress( + totalAssignmentsCount: course.courseProgress?.totalAssignmentsCount ?? 0, + assignmentsCompleted: course.courseProgress?.assignmentsCompleted ?? 0 + ) ) } @@ -128,7 +132,9 @@ public class CourseInteractor: CourseInteractorProtocol { displayName: sequential.displayName, type: sequential.type, completion: sequential.completion, - childs: newChilds + childs: newChilds, + sequentialProgress: sequential.sequentialProgress, + due: sequential.due ) } diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index f2678a915..51e687f3e 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -16,8 +16,8 @@ public enum CourseTab: Int, CaseIterable, Identifiable { } case course case videos - case discussion case dates + case discussion case handounds } @@ -68,6 +68,9 @@ public class CourseContainerViewModel: BaseCourseViewModel { @Published var userSettings: UserSettings? @Published var isInternetAvaliable: Bool = true @Published var dueDatesShifted: Bool = false + @Published var updateCourseProgress: Bool = false + + let completionPublisher = NotificationCenter.default.publisher(for: .onblockCompletionRequested) var errorMessage: String? { didSet { @@ -137,6 +140,13 @@ public class CourseContainerViewModel: BaseCourseViewModel { addObservers() } + func updateCourseIfNeeded(courseID: String) async { + if updateCourseProgress { + await getCourseBlocks(courseID: courseID, withProgress: false) + updateCourseProgress = false + } + } + func openLastVisitedBlock() { guard let continueWith = continueWith, let courseStructure = courseStructure else { return } @@ -607,6 +617,13 @@ public class CourseContainerViewModel: BaseCourseViewModel { selector: #selector(handleShiftDueDates), name: .shiftCourseDates, object: nil ) + + completionPublisher + .sink { [weak self] _ in + guard let self = self else { return } + updateCourseProgress = true + } + .store(in: &cancellables) } deinit { diff --git a/Course/Course/Presentation/Outline/ContinueWithView.swift b/Course/Course/Presentation/Outline/ContinueWithView.swift index 1c05c575b..45271c4ae 100644 --- a/Course/Course/Presentation/Outline/ContinueWithView.swift +++ b/Course/Course/Presentation/Outline/ContinueWithView.swift @@ -83,6 +83,7 @@ struct ContinueWithView_Previews: PreviewProvider { id: "1", courseId: "123", graded: true, + due: Date(), completion: 0, type: .html, displayName: "Continue lesson", @@ -96,6 +97,7 @@ struct ContinueWithView_Previews: PreviewProvider { id: "2", courseId: "123", graded: true, + due: Date(), completion: 0, type: .html, displayName: "Continue lesson", diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 936ba835e..cb7e1bec2 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -13,7 +13,7 @@ import SwiftUIIntrospect public struct CourseOutlineView: View { - @ObservedObject private var viewModel: CourseContainerViewModel + @StateObject private var viewModel: CourseContainerViewModel private let title: String private let courseID: String private let isVideo: Bool @@ -30,6 +30,8 @@ public struct CourseOutlineView: View { @Binding private var coordinate: CGFloat @Binding private var collapsed: Bool + @State private var expandedChapters: [String: Bool] = [:] + public init( viewModel: CourseContainerViewModel, title: String, @@ -41,7 +43,7 @@ public struct CourseOutlineView: View { dateTabIndex: Int ) { self.title = title - self.viewModel = viewModel//StateObject(wrappedValue: { viewModel }()) + self._viewModel = StateObject(wrappedValue: { viewModel }()) self.courseID = courseID self.isVideo = isVideo self._selection = selection @@ -52,10 +54,8 @@ public struct CourseOutlineView: View { public var body: some View { ZStack(alignment: .top) { - // MARK: - Page name GeometryReader { proxy in VStack(alignment: .center) { - // MARK: - Page Body RefreshableScrollViewCompat(action: { await withTaskGroup(of: Void.self) { group in group.addTask { @@ -95,7 +95,6 @@ public struct CourseOutlineView: View { let sequential = chapter.childs[continueWith.sequentialIndex] let continueUnit = sequential.childs[continueWith.verticalIndex] - // MARK: - ContinueWith button ContinueWithView( data: continueWith, courseContinueUnit: continueUnit @@ -108,21 +107,19 @@ public struct CourseOutlineView: View { ? viewModel.courseVideosStructure : viewModel.courseStructure { - // MARK: - Sections - if viewModel.config.uiComponents.courseNestedListEnabled { - CourseStructureNestedListView( - proxy: proxy, - course: course, - viewModel: viewModel - ) - } else { - CourseStructureView( - proxy: proxy, - course: course, - viewModel: viewModel - ) + if !isVideo, let progress = course.courseProgress, progress.totalAssignmentsCount != 0 { + CourseProgressView(progress: progress) + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 8) } + // MARK: - Sections + CustomDisclosureGroup( + course: course, + proxy: proxy, + viewModel: viewModel + ) } else { if let courseStart = viewModel.courseStart { Text(courseStart > Date() ? CourseLocalization.Outline.courseHasntStarted : "") @@ -190,6 +187,11 @@ public struct CourseOutlineView: View { } } } + .onAppear { + Task { + await viewModel.updateCourseIfNeeded(courseID: courseID) + } + } .background( Theme.Colors.background .ignoresSafeArea() diff --git a/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift b/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift deleted file mode 100644 index 6e8ad3927..000000000 --- a/Course/Course/Presentation/Outline/CourseStructure/CourseStructureNestedListView.swift +++ /dev/null @@ -1,237 +0,0 @@ -// -// CourseStructureNestedListView.swift -// Course -// -// Created by Eugene Yatsenko on 09.11.2023. -// - -import SwiftUI -import Core -import Kingfisher -import Theme - -struct CourseStructureNestedListView: View { - - private let proxy: GeometryProxy - private let course: CourseStructure - private let viewModel: CourseContainerViewModel - private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } - - @State private var isExpandedIds: [String] = [] - - init(proxy: GeometryProxy, course: CourseStructure, viewModel: CourseContainerViewModel) { - self.proxy = proxy - self.course = course - self.viewModel = viewModel - } - - var body: some View { - ForEach(course.childs, content: disclosureGroup) - } - - private func disclosureGroup(chapter: CourseChapter) -> some View { - CustomDisclosureGroup( - animation: .easeInOut(duration: 0.2), - isExpanded: .constant(isExpandedIds.contains(where: { $0 == chapter.id })), - onClick: { onHeaderClick(chapter: chapter) }, - header: { isExpanded in header(chapter: chapter, isExpanded: isExpanded) }, - content: { section(chapter: chapter) } - ) - } - - private func header( - chapter: CourseChapter, - isExpanded: Bool - ) -> some View { - HStack { - Text(chapter.displayName) - .font(Theme.Fonts.titleMedium) - .multilineTextAlignment(.leading) - .lineLimit(1) - .foregroundColor(Theme.Colors.textPrimary) - Spacer() - Image(systemName: "chevron.down").renderingMode(.template) - .foregroundColor(Theme.Colors.accentXColor) - .dropdownArrowRotationAnimation(value: isExpanded) - } - .padding(.horizontal, 30) - .padding(.vertical, 15) - } - - private func section(chapter: CourseChapter) -> some View { - ForEach(chapter.childs) { sequential in - VStack(spacing: 0) { - sequentialLabel( - sequential: sequential, - chapter: chapter, - isExpanded: false - ) - } - } - } - - @ViewBuilder - private func sequentialLabel( - sequential: CourseSequential, - chapter: CourseChapter, - isExpanded: Bool - ) -> some View { - HStack { - Button { - onLabelClick(sequential: sequential, chapter: chapter) - } label: { - 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) - } - .foregroundColor(Theme.Colors.textPrimary) - Spacer() - } - } - downloadButton( - sequential: sequential, - chapter: chapter - ) - - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(sequential.displayName) - .padding(.leading, 40) - .padding(.trailing, 28) - .padding(.vertical, 14) - } - - @ViewBuilder - private func downloadButton( - sequential: CourseSequential, - chapter: CourseChapter - ) -> some View { - if let state = viewModel.sequentialsDownloadState[sequential.id] { - switch state { - case .available: - if viewModel.isInternetAvaliable { - Button { - Task { - await viewModel.onDownloadViewTap( - chapter: chapter, - blockId: sequential.id, - state: state - ) - } - } label: { - DownloadAvailableView() - .accessibilityElement(children: .ignore) - .accessibilityLabel(CourseLocalization.Accessibility.download) - } - } - case .downloading: - if viewModel.isInternetAvaliable { - Button { - viewModel.router.showDownloads( - downloads: viewModel.getTasks(sequential: sequential), - manager: viewModel.manager - ) - } label: { - ProgressBar(size: 30, lineWidth: 1.75) - } - } - case .finished: - Button { - viewModel.router.presentAlert( - alertTitle: "Warning", - alertMessage: "\(CourseLocalization.Alert.deleteVideos) \"\(sequential.displayName)\"?", - positiveAction: CoreLocalization.Alert.delete, - onCloseTapped: { - viewModel.router.dismiss(animated: true) - }, - okTapped: { - Task { - await viewModel.onDownloadViewTap( - chapter: chapter, - blockId: sequential.id, - state: state - ) - } - viewModel.router.dismiss(animated: true) - }, - type: .deleteVideo - ) - } label: { - DownloadFinishedView() - .accessibilityElement(children: .ignore) - .accessibilityLabel(CourseLocalization.Accessibility.deleteDownload) - } - } - downloadCount(sequential: sequential) - } - } - - @ViewBuilder - private func downloadCount(sequential: CourseSequential) -> some View { - let downloadable = viewModel.verticalsBlocksDownloadable(by: sequential) - if !downloadable.isEmpty { - Text(String(downloadable.count)) - .foregroundColor(Color(UIColor.label)) - } - } - - private func onHeaderClick(chapter: CourseChapter) { - if let index = isExpandedIds.firstIndex(where: {$0 == chapter.id}) { - isExpandedIds.remove(at: index) - } else { - isExpandedIds.append(chapter.id) - } - } - - private func onLabelClick( - sequential: CourseSequential, - chapter: CourseChapter - ) { - guard let chapterIndex = course.childs.firstIndex( - where: { $0.id == chapter.id } - ) else { - return - } - - guard let sequentialIndex = chapter.childs.firstIndex( - where: { $0.id == sequential.id } - ) else { - return - } - - guard let courseVertical = sequential.childs.first else { - return - } - - guard let block = courseVertical.childs.first else { - return - } - - viewModel.trackVerticalClicked( - courseId: viewModel.courseStructure?.id ?? "", - courseName: viewModel.courseStructure?.displayName ?? "", - vertical: courseVertical - ) - viewModel.router.showCourseUnit( - courseName: viewModel.courseStructure?.displayName ?? "", - blockId: block.id, - courseID: viewModel.courseStructure?.id ?? "", - verticalIndex: 0, - chapters: course.childs, - chapterIndex: chapterIndex, - sequentialIndex: sequentialIndex - ) - - } - -} diff --git a/Course/Course/Presentation/Outline/CourseStructure/CourseStructureView.swift b/Course/Course/Presentation/Outline/CourseStructure/CourseStructureView.swift deleted file mode 100644 index e2dd8646d..000000000 --- a/Course/Course/Presentation/Outline/CourseStructure/CourseStructureView.swift +++ /dev/null @@ -1,134 +0,0 @@ -// -// CourseStructureView.swift -// Course -// -// Created by Eugene Yatsenko on 15.12.2023. -// - -import SwiftUI -import Core -import Theme - -struct CourseStructureView: View { - - private let proxy: GeometryProxy - private let course: CourseStructure - private let viewModel: CourseContainerViewModel - private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } - - init(proxy: GeometryProxy, course: CourseStructure, viewModel: CourseContainerViewModel) { - self.proxy = proxy - self.course = course - self.viewModel = viewModel - } - - var body: some View { - let chapters = course.childs - ForEach(chapters, id: \.id) { chapter in - let chapterIndex = chapters.firstIndex(where: { $0.id == chapter.id }) - Text(chapter.displayName) - .font(Theme.Fonts.titleMedium) - .multilineTextAlignment(.leading) - .foregroundColor(Theme.Colors.textSecondary) - .padding(.horizontal, 24) - .padding(.top, 40) - ForEach(chapter.childs, id: \.id) { child in - let sequentialIndex = chapter.childs.firstIndex(where: { $0.id == child.id }) - VStack(alignment: .leading) { - HStack { - Button { - if let chapterIndex, let sequentialIndex { - viewModel.trackSequentialClicked(child) - viewModel.router.showCourseVerticalView( - courseID: viewModel.courseStructure?.id ?? "", - courseName: viewModel.courseStructure?.displayName ?? "", - title: child.displayName, - chapters: chapters, - chapterIndex: chapterIndex, - sequentialIndex: sequentialIndex - ) - } - } label: { - Group { - if child.completion == 1 { - CoreAssets.finished.swiftUIImage - .renderingMode(.template) - .foregroundColor(Theme.Colors.accentXColor) - } else { - child.type.image - } - Text(child.displayName) - .font(Theme.Fonts.titleMedium) - .multilineTextAlignment(.leading) - .lineLimit(1) - .frame( - maxWidth: idiom == .pad - ? proxy.size.width * 0.5 - : proxy.size.width * 0.6, - alignment: .leading - ) - } - .foregroundColor(Theme.Colors.textPrimary) - } - .accessibilityElement(children: .ignore) - .accessibilityLabel(child.displayName) - Spacer() - if let state = viewModel.sequentialsDownloadState[child.id] { - switch state { - case .available: - DownloadAvailableView() - .accessibilityElement(children: .ignore) - .accessibilityLabel(CourseLocalization.Accessibility.download) - .onTapGesture { - Task { - await viewModel.onDownloadViewTap( - chapter: chapter, - blockId: child.id, - state: state - ) - } - } - case .downloading: - DownloadProgressView() - .accessibilityElement(children: .ignore) - .accessibilityLabel(CourseLocalization.Accessibility.cancelDownload) - .onTapGesture { - Task { - await viewModel.onDownloadViewTap( - chapter: chapter, - blockId: child.id, - state: state - ) - } - } - case .finished: - DownloadFinishedView() - .accessibilityElement(children: .ignore) - .accessibilityLabel(CourseLocalization.Accessibility.deleteDownload) - .onTapGesture { - Task { - await viewModel.onDownloadViewTap( - chapter: chapter, - blockId: child.id, - state: state - ) - } - } - } - } - Image(systemName: "chevron.right") - .foregroundColor(Theme.Colors.accentColor) - } - .padding(.horizontal, 36) - .padding(.vertical, 20) - if chapterIndex != chapters.count - 1 { - Divider() - .frame(height: 1) - .overlay(Theme.Colors.cardViewStroke) - .padding(.horizontal, 24) - } - } - } - } - } -} diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift index 5d33fbd69..bc3722da1 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift @@ -34,7 +34,8 @@ struct CourseVerticalImageView_Previews: PreviewProvider { id: "1", courseId: "123", topicId: "1", - graded: false, + graded: false, + due: Date(), completion: 1, type: .video, displayName: "Block 1", @@ -52,6 +53,7 @@ struct CourseVerticalImageView_Previews: PreviewProvider { courseId: "123", topicId: "1", graded: false, + due: Date(), completion: 1, type: .problem, displayName: "Block 1", @@ -68,6 +70,7 @@ struct CourseVerticalImageView_Previews: PreviewProvider { courseId: "123", topicId: "1", graded: false, + due: Date(), completion: 1, type: .discussion, displayName: "Block 1", @@ -84,6 +87,7 @@ struct CourseVerticalImageView_Previews: PreviewProvider { courseId: "123", topicId: "1", graded: false, + due: Date(), completion: 1, type: .html, displayName: "Block 1", @@ -100,6 +104,7 @@ struct CourseVerticalImageView_Previews: PreviewProvider { courseId: "123", topicId: "1", graded: false, + due: Date(), completion: 1, type: .unknown, displayName: "Block 1", diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift index d16095762..ccfc6f9b7 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift @@ -203,7 +203,14 @@ struct CourseVerticalView_Previews: PreviewProvider { type: .vertical, completion: 0, childs: []) - ]) + ], + sequentialProgress: SequentialProgress( + assignmentType: "Advanced Assessment Tools", + numPointsEarned: 1, + numPointsPossible: 3 + ), + due: Date() + ) ]) ] diff --git a/Course/Course/Presentation/Subviews/CourseProgressView.swift b/Course/Course/Presentation/Subviews/CourseProgressView.swift new file mode 100644 index 000000000..70ee1c2d8 --- /dev/null +++ b/Course/Course/Presentation/Subviews/CourseProgressView.swift @@ -0,0 +1,54 @@ +// +// CourseProgressView.swift +// Course +// +// Created by  Stepanok Ivan on 23.05.2024. +// + +import SwiftUI +import Theme +import Core + +public struct CourseProgressView: View { + private var progress: CourseProgress + + public init(progress: CourseProgress) { + self.progress = progress + } + + public var body: some View { + VStack(alignment: .leading) { + ZStack(alignment: .leading) { + GeometryReader { geometry in + RoundedRectangle(cornerRadius: 10) + .fill(Theme.Colors.textSecondary.opacity(0.5)) + .frame(width: geometry.size.width, height: 10) + + if let total = progress.totalAssignmentsCount, + let completed = progress.assignmentsCompleted { + RoundedCorners(tl: 5, tr: 0, bl: 5, br: 0) + .fill(Theme.Colors.accentColor) + .frame(width: geometry.size.width * CGFloat(completed) / CGFloat(total), height: 10) + } + } + .frame(height: 10) + } + .cornerRadius(10) + + if let total = progress.totalAssignmentsCount, + let completed = progress.assignmentsCompleted { + Text(CourseLocalization.Course.progressCompleted(completed, total)) + .foregroundColor(Theme.Colors.textPrimary) + .font(Theme.Fonts.labelSmall) + .padding(.top, 4) + } + } + } +} + +struct CourseProgressView_Previews: PreviewProvider { + static var previews: some View { + CourseProgressView(progress: CourseProgress(totalAssignmentsCount: 20, assignmentsCompleted: 12)) + .padding() + } +} diff --git a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift new file mode 100644 index 000000000..75186dd91 --- /dev/null +++ b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift @@ -0,0 +1,400 @@ +// +// CustomDisclosureGroup.swift +// Course +// +// Created by  Stepanok Ivan on 21.05.2024. +// + +import SwiftUI +import Core +import Theme + +struct CustomDisclosureGroup: View { + @State private var expandedSections: [String: Bool] = [:] + + private let proxy: GeometryProxy + private let course: CourseStructure + private let viewModel: CourseContainerViewModel + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } + + init(course: CourseStructure, proxy: GeometryProxy, viewModel: CourseContainerViewModel) { + self.course = course + self.proxy = proxy + self.viewModel = viewModel + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + ForEach(course.childs) { chapter in + let chapterIndex = course.childs.firstIndex(where: { $0.id == chapter.id }) + VStack(alignment: .leading) { + Button( + action: { + withAnimation(.linear(duration: course.childs.count > 1 ? 0.2 : 0.05)) { + expandedSections[chapter.id, default: false].toggle() + } + }, label: { + HStack { + CoreAssets.chevronRight.swiftUIImage + .rotationEffect(.degrees(expandedSections[chapter.id] ?? false ? -90 : 90)) + .foregroundColor(Theme.Colors.textPrimary) + if chapter.childs.allSatisfy({ $0.completion == 1 }) { + CoreAssets.finishedSequence.swiftUIImage + } + Text(chapter.displayName) + .font(Theme.Fonts.titleMedium) + .foregroundColor(Theme.Colors.textPrimary) + .lineLimit(1) + Spacer() + if canDownloadAllSections(in: chapter), + let state = downloadAllButtonState(for: chapter) { + Button( + action: { + downloadAllSubsections(in: chapter, state: state) + }, label: { + switch state { + case .available: + DownloadAvailableView() + case .downloading: + DownloadProgressView() + case .finished: + DownloadFinishedView() + } + + } + ) + } + } + } + ) + if expandedSections[chapter.id] ?? false { + VStack(alignment: .leading) { + ForEach(chapter.childs) { sequential in + let sequentialIndex = chapter.childs.firstIndex(where: { $0.id == sequential.id }) + VStack(alignment: .leading) { + HStack { + Button( + action: { + guard let chapterIndex = chapterIndex else { return } + guard let sequentialIndex else { return } + guard let courseVertical = sequential.childs.first else { return } + guard let block = courseVertical.childs.first else { return } + + viewModel.trackSequentialClicked(sequential) + if viewModel.config.uiComponents.courseDropDownNavigationEnabled { + viewModel.router.showCourseUnit( + courseName: viewModel.courseStructure?.displayName ?? "", + blockId: block.id, + courseID: viewModel.courseStructure?.id ?? "", + verticalIndex: 0, + chapters: course.childs, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex + ) + } else { + viewModel.router.showCourseVerticalView( + courseID: viewModel.courseStructure?.id ?? "", + courseName: viewModel.courseStructure?.displayName ?? "", + title: sequential.displayName, + chapters: course.childs, + chapterIndex: chapterIndex, + sequentialIndex: sequentialIndex + ) + } + }, + label: { + VStack(alignment: .leading) { + HStack { + if sequential.completion == 1 { + CoreAssets.finishedSequence.swiftUIImage + .resizable() + .frame(width: 20, height: 20) + } else { + sequential.type.image + } + Text(sequential.displayName) + .font(Theme.Fonts.titleSmall) + .multilineTextAlignment(.leading) + .lineLimit(1) + .frame( + maxWidth: idiom == .pad + ? proxy.size.width * 0.5 + : proxy.size.width * 0.6, + alignment: .leading + ) + } + if let sequentialProgress = sequential.sequentialProgress, + let assignmentType = sequentialProgress.assignmentType, + let numPointsEarned = sequentialProgress.numPointsEarned, + let numPointsPossible = sequentialProgress.numPointsPossible, + let due = sequential.due { + let daysRemaining = getAssignmentStatus(for: due) + Text("\(assignmentType) - \(daysRemaining) - \(numPointsEarned) / \(numPointsPossible)") + .font(Theme.Fonts.bodySmall) + .multilineTextAlignment(.leading) + .lineLimit(2) + } + } + .foregroundColor(Theme.Colors.textPrimary) + .accessibilityElement(children: .ignore) + .accessibilityLabel(sequential.displayName) + } + ) + Spacer() + if sequential.due != nil { + CoreAssets.chevronRight.swiftUIImage + .foregroundColor(Theme.Colors.textPrimary) + } + } + .padding(.vertical, 4) + } + } + } + + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Theme.Colors.tabbarColor) + ) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(style: .init(lineWidth: 1, lineCap: .round, lineJoin: .round, miterLimit: 1)) + .foregroundColor(Theme.Colors.cardViewStroke) + ) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 8) + .onFirstAppear { + for chapter in course.childs { + expandedSections[chapter.id] = false + } + } + } + + func getAssignmentStatus(for date: Date) -> String { + let calendar = Calendar.current + let today = Date() + + if calendar.isDateInToday(date) { + return CourseLocalization.Course.dueToday + } else if calendar.isDateInTomorrow(date) { + return CourseLocalization.Course.dueTomorrow + } else if let daysUntil = calendar.dateComponents([.day], from: today, to: date).day, daysUntil > 0 { + return CourseLocalization.dueIn(daysUntil) + } else if let daysAgo = calendar.dateComponents([.day], from: date, to: today).day, daysAgo > 0 { + return CourseLocalization.pastDue(daysAgo) + } else { + return "" + } + } + + private func canDownloadAllSections(in chapter: CourseChapter) -> Bool { + for sequential in chapter.childs { + if let state = viewModel.sequentialsDownloadState[sequential.id] { + return true + } + } + return false + } + + private func downloadAllSubsections(in chapter: CourseChapter, state: DownloadViewState) { + Task { + for sequential in chapter.childs { + await viewModel.onDownloadViewTap( + chapter: chapter, + blockId: sequential.id, + state: state + ) + } + } + } + + private func downloadAllButtonState(for chapter: CourseChapter) -> DownloadViewState? { + if canDownloadAllSections(in: chapter) { + let downloads = chapter.childs.filter({ viewModel.sequentialsDownloadState[$0.id] != nil }) + + if downloads.contains(where: { viewModel.sequentialsDownloadState[$0.id] == .downloading }) { + return .downloading + } else if downloads.allSatisfy({ viewModel.sequentialsDownloadState[$0.id] == .finished }) { + return .finished + } else { + return .available + } + } + return nil + } + +} + +#if DEBUG +struct CustomDisclosureGroup_Previews: PreviewProvider { + + static var previews: some View { + + // Sample data models + let sampleCourseChapters: [CourseChapter] = [ + CourseChapter( + blockId: "1", + id: "1", + displayName: "Chapter 1", + type: .chapter, + childs: [ + CourseSequential( + blockId: "1-1", + id: "1-1", + displayName: "Sequential 1", + type: .sequential, + completion: 0, + childs: [ + CourseVertical( + blockId: "1-1-1", + id: "1-1-1", + courseId: "1", + displayName: "Vertical 1", + type: .vertical, + completion: 0, + childs: [] + ), + CourseVertical( + blockId: "1-1-2", + id: "1-1-2", + courseId: "1", + displayName: "Vertical 2", + type: .vertical, + completion: 1.0, + childs: [] + ) + ], + sequentialProgress: SequentialProgress( + assignmentType: "Advanced Assessment Tools", + numPointsEarned: 1, + numPointsPossible: 3 + ), + due: Date() + ), + CourseSequential( + blockId: "1-2", + id: "1-2", + displayName: "Sequential 2", + type: .sequential, + completion: 1.0, + childs: [ + CourseVertical( + blockId: "1-2-1", + id: "1-2-1", + courseId: "1", + displayName: "Vertical 3", + type: .vertical, + completion: 1.0, + childs: [] + ) + ], + sequentialProgress: SequentialProgress( + assignmentType: "Basic Assessment Tools", + numPointsEarned: 1, + numPointsPossible: 3 + ), + due: Date() + ) + ] + ), + CourseChapter( + blockId: "2", + id: "2", + displayName: "Chapter 2", + type: .chapter, + childs: [ + CourseSequential( + blockId: "2-1", + id: "2-1", + displayName: "Sequential 3", + type: .sequential, + completion: 1.0, + childs: [ + CourseVertical( + blockId: "2-1-1", + id: "2-1-1", + courseId: "2", + displayName: "Vertical 4", + type: .vertical, + completion: 1.0, + childs: [] + ), + CourseVertical( + blockId: "2-1-2", + id: "2-1-2", + courseId: "2", + displayName: "Vertical 5", + type: .vertical, + completion: 1.0, + childs: [] + ) + ], + sequentialProgress: SequentialProgress( + assignmentType: "Advanced Assessment Tools", + numPointsEarned: 1, + numPointsPossible: 3 + ), + due: Date() + ) + ] + ) + ] + + let viewModel = CourseContainerViewModel( + interactor: CourseInteractor.mock, + authInteractor: AuthInteractor.mock, + router: CourseRouterMock(), + analytics: CourseAnalyticsMock(), + config: ConfigMock(), + connectivity: Connectivity(), + manager: DownloadManagerMock(), + storage: CourseStorageMock(), + isActive: true, + courseStart: Date(), + courseEnd: nil, + enrollmentStart: Date(), + enrollmentEnd: nil, + lastVisitedBlockID: nil, + coreAnalytics: CoreAnalyticsMock() + ) + Task { + await withTaskGroup(of: Void.self) { group in + group.addTask { + await viewModel.getCourseBlocks(courseID: "courseId") + } + group.addTask { + await viewModel.getCourseDeadlineInfo(courseID: "courseId") + } + } + } + + return GeometryReader { proxy in + ScrollView { + CustomDisclosureGroup( + course: CourseStructure( + id: "Id", + graded: false, + completion: 0, + viewYouTubeUrl: "", + encodedVideo: "", + displayName: "Course", + childs: sampleCourseChapters, + media: DataLayer.CourseMedia.init(image: DataLayer.Image(raw: "", small: "", large: "")), + certificate: nil, + org: "org", + isSelfPaced: false, + courseProgress: nil + ), + proxy: proxy, + viewModel: viewModel + ) + } + } + } +} +#endif diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index dff0a0ea9..44f1e4a1b 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -442,6 +442,7 @@ struct CourseUnitView_Previews: PreviewProvider { courseId: "123", topicId: "1", graded: false, + due: Date(), completion: 0, type: .video, displayName: "Lesson 1", @@ -456,6 +457,7 @@ struct CourseUnitView_Previews: PreviewProvider { courseId: "123", topicId: "2", graded: false, + due: Date(), completion: 0, type: .video, displayName: "Lesson 2", @@ -470,6 +472,7 @@ struct CourseUnitView_Previews: PreviewProvider { courseId: "123", topicId: "3", graded: false, + due: Date(), completion: 0, type: .unknown, displayName: "Lesson 3", @@ -484,6 +487,7 @@ struct CourseUnitView_Previews: PreviewProvider { courseId: "123", topicId: "4", graded: false, + due: Date(), completion: 0, type: .unknown, displayName: "4", @@ -517,10 +521,17 @@ struct CourseUnitView_Previews: PreviewProvider { completion: 0, childs: blocks ) - ] + ], + sequentialProgress: SequentialProgress( + assignmentType: "Advanced Assessment Tools", + numPointsEarned: 1, + numPointsPossible: 3 + ), + due: Date() ) - ]), + ] + ), CourseChapter( blockId: "2", id: "2", @@ -543,7 +554,13 @@ struct CourseUnitView_Previews: PreviewProvider { completion: 0, childs: blocks ) - ] + ], + sequentialProgress: SequentialProgress( + assignmentType: "Basic Assessment Tools", + numPointsEarned: 1, + numPointsPossible: 3 + ), + due: Date() ) ]) diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift index 397bf332a..7b7310fb4 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift @@ -77,6 +77,7 @@ struct CourseUnitDropDownCell_Previews: PreviewProvider { courseId: "123", topicId: "1", graded: false, + due: Date(), completion: 1, type: .video, displayName: "Lesson 1", diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift index f338a202f..fea9801f3 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift @@ -51,6 +51,7 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { courseId: "123", topicId: "1", graded: false, + due: Date(), completion: 0, type: .video, displayName: "Lesson 1", @@ -65,6 +66,7 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { courseId: "123", topicId: "2", graded: false, + due: Date(), completion: 0, type: .video, displayName: "Lesson 2", @@ -79,6 +81,7 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { courseId: "123", topicId: "3", graded: false, + due: Date(), completion: 0, type: .unknown, displayName: "Lesson 3", @@ -93,6 +96,7 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { courseId: "123", topicId: "4", graded: false, + due: Date(), completion: 0, type: .unknown, displayName: "4", diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift index 805c709a1..dd7ddbc75 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift @@ -64,6 +64,7 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { courseId: "123", topicId: "1", graded: false, + due: Date(), completion: 0, type: .video, displayName: "Lesson 1", @@ -79,6 +80,7 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { courseId: "123", topicId: "2", graded: false, + due: Date(), completion: 0, type: .video, displayName: "Lesson 2", @@ -94,6 +96,7 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { courseId: "123", topicId: "3", graded: false, + due: Date(), completion: 0, type: .unknown, displayName: "Lesson 3", @@ -109,6 +112,7 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { courseId: "123", topicId: "4", graded: false, + due: Date(), completion: 0, type: .unknown, displayName: "4", diff --git a/Course/Course/SwiftGen/Strings.swift b/Course/Course/SwiftGen/Strings.swift index a66bfae07..1cada7b12 100644 --- a/Course/Course/SwiftGen/Strings.swift +++ b/Course/Course/SwiftGen/Strings.swift @@ -10,6 +10,14 @@ import Foundation // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces public enum CourseLocalization { + /// Plural format key: "%#@due_in@" + public static func dueIn(_ p1: Int) -> String { + return CourseLocalization.tr("Localizable", "due_in", p1, fallback: "Plural format key: \"%#@due_in@\"") + } + /// Plural format key: "%#@past_due@" + public static func pastDue(_ p1: Int) -> String { + return CourseLocalization.tr("Localizable", "past_due", p1, fallback: "Plural format key: \"%#@past_due@\"") + } public enum Accessibility { /// Cancel download public static let cancelDownload = CourseLocalization.tr("Localizable", "ACCESSIBILITY.CANCEL_DOWNLOAD", fallback: "Cancel download") @@ -30,6 +38,16 @@ public enum CourseLocalization { /// Turning off the switch will stop downloading and delete all downloaded videos for public static let stopDownloading = CourseLocalization.tr("Localizable", "ALERT.STOP_DOWNLOADING", fallback: "Turning off the switch will stop downloading and delete all downloaded videos for") } + public enum Course { + /// Due Today + public static let dueToday = CourseLocalization.tr("Localizable", "COURSE.DUE_TODAY", fallback: "Due Today") + /// Due Tomorrow + public static let dueTomorrow = CourseLocalization.tr("Localizable", "COURSE.DUE_TOMORROW", fallback: "Due Tomorrow") + /// %@ of %@ assignments complete + public static func progressCompleted(_ p1: Any, _ p2: Any) -> String { + return CourseLocalization.tr("Localizable", "COURSE.PROGRESS_COMPLETED", String(describing: p1), String(describing: p2), fallback: "%@ of %@ assignments complete") + } + } public enum Courseware { /// Back to outline public static let backToOutline = CourseLocalization.tr("Localizable", "COURSEWARE.BACK_TO_OUTLINE", fallback: "Back to outline") diff --git a/Course/Course/en.lproj/Localizable.strings b/Course/Course/en.lproj/Localizable.strings index 90c6472f3..40a4d8157 100644 --- a/Course/Course/en.lproj/Localizable.strings +++ b/Course/Course/en.lproj/Localizable.strings @@ -120,3 +120,8 @@ "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"; + +"COURSE.DUE_TODAY" = "Due Today"; +"COURSE.DUE_TOMORROW" = "Due Tomorrow"; + +"COURSE.PROGRESS_COMPLETED" = "%@ of %@ assignments complete"; diff --git a/Course/Course/en.lproj/Localizable.stringsdict b/Course/Course/en.lproj/Localizable.stringsdict new file mode 100644 index 000000000..ccfae8233 --- /dev/null +++ b/Course/Course/en.lproj/Localizable.stringsdict @@ -0,0 +1,42 @@ + + + + + due_in + + NSStringLocalizedFormatKey + %#@due_in@ + due_in + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + zero + Today + one + Due in %d day + other + Due in %d days + + + past_due + + NSStringLocalizedFormatKey + %#@past_due@ + past_due + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + zero + Today + one + Due %d day ago + other + Due %d days ago + + + + diff --git a/Course/Course/uk.lproj/Localizable.strings b/Course/Course/uk.lproj/Localizable.strings index 59a57991a..abb9dc970 100644 --- a/Course/Course/uk.lproj/Localizable.strings +++ b/Course/Course/uk.lproj/Localizable.strings @@ -119,3 +119,8 @@ "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"; + +"COURSE.DUE_TODAY" = "Закінчується сьогодні"; +"COURSE.DUE_TOMORROW" = "Закінчується завтра"; + +"COURSE.PROGRESS_COMPLETED" = "%@ з %@ завдань виконано"; diff --git a/Course/Course/uk.lproj/Localizable.stringsdict b/Course/Course/uk.lproj/Localizable.stringsdict new file mode 100644 index 000000000..0b7ac9460 --- /dev/null +++ b/Course/Course/uk.lproj/Localizable.stringsdict @@ -0,0 +1,42 @@ + + + + + due_in + + NSStringLocalizedFormatKey + %#@due_in@ + due_in + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + zero + Today + one + Прострочено на %d день + other + Прострочено на %d днів + + + past_due + + NSStringLocalizedFormatKey + %#@past_due@ + past_due + + NSStringFormatSpecTypeKey + NSStringPluralRuleType + NSStringFormatValueTypeKey + d + zero + Today + one + Залишився %d день + other + Залишилося %d днів + + + + diff --git a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift index cd69ebbb3..144614179 100644 --- a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift @@ -49,7 +49,8 @@ final class CourseContainerViewModelTests: XCTestCase { id: "", courseId: "123", topicId: "", - graded: true, + graded: true, + due: Date(), completion: 0, type: .problem, displayName: "", @@ -73,7 +74,9 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .chapter, completion: 0, - childs: [vertical] + childs: [vertical], + sequentialProgress: nil, + due: Date() ) let chapter = CourseChapter( blockId: "", @@ -99,7 +102,8 @@ final class CourseContainerViewModelTests: XCTestCase { large: "")), certificate: nil, org: "", - isSelfPaced: true + isSelfPaced: true, + courseProgress: nil ) let resumeBlock = ResumeBlock(blockID: "123") @@ -167,7 +171,8 @@ final class CourseContainerViewModelTests: XCTestCase { large: "")), certificate: nil, org: "", - isSelfPaced: true + isSelfPaced: true, + courseProgress: nil ) Given(interactor, .getLoadedCourseBlocks(courseID: .any, willReturn: courseStructure)) @@ -369,6 +374,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseId: "123", topicId: "", graded: false, + due: Date(), completion: 0, type: .video, displayName: "", @@ -402,7 +408,9 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .chapter, completion: 0, - childs: [vertical] + childs: [vertical], + sequentialProgress: nil, + due: Date() ) let chapter = CourseChapter( @@ -429,7 +437,8 @@ final class CourseContainerViewModelTests: XCTestCase { )), certificate: nil, org: "", - isSelfPaced: true + isSelfPaced: true, + courseProgress: nil ) let downloadData = DownloadDataTask( @@ -507,6 +516,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseId: "123", topicId: "", graded: false, + due: Date(), completion: 0, type: .video, displayName: "", @@ -539,7 +549,9 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .chapter, completion: 0, - childs: [vertical] + childs: [vertical], + sequentialProgress: nil, + due: Date() ) let chapter = CourseChapter( @@ -566,7 +578,8 @@ final class CourseContainerViewModelTests: XCTestCase { )), certificate: nil, org: "", - isSelfPaced: true + isSelfPaced: true, + courseProgress: nil ) Given(connectivity, .isInternetAvaliable(getter: true)) @@ -629,6 +642,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseId: "123", topicId: "", graded: false, + due: Date(), completion: 0, type: .video, displayName: "", @@ -661,7 +675,9 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .chapter, completion: 0, - childs: [vertical] + childs: [vertical], + sequentialProgress: nil, + due: Date() ) let chapter = CourseChapter( @@ -688,7 +704,8 @@ final class CourseContainerViewModelTests: XCTestCase { )), certificate: nil, org: "", - isSelfPaced: true + isSelfPaced: true, + courseProgress: nil ) Given(connectivity, .isInternetAvaliable(getter: true)) @@ -752,6 +769,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseId: "123", topicId: "", graded: false, + due: Date(), completion: 0, type: .video, displayName: "", @@ -784,7 +802,9 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .chapter, completion: 0, - childs: [vertical] + childs: [vertical], + sequentialProgress: nil, + due: Date() ) let chapter = CourseChapter( @@ -811,7 +831,8 @@ final class CourseContainerViewModelTests: XCTestCase { )), certificate: nil, org: "", - isSelfPaced: true + isSelfPaced: true, + courseProgress: nil ) Given(connectivity, .isInternetAvaliable(getter: true)) @@ -868,6 +889,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseId: "123", topicId: "", graded: false, + due: Date(), completion: 0, type: .video, displayName: "", @@ -900,7 +922,9 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .chapter, completion: 0, - childs: [vertical] + childs: [vertical], + sequentialProgress: nil, + due: Date() ) let chapter = CourseChapter( @@ -927,7 +951,8 @@ final class CourseContainerViewModelTests: XCTestCase { )), certificate: nil, org: "", - isSelfPaced: true + isSelfPaced: true, + courseProgress: nil ) let downloadData = DownloadDataTask( @@ -999,6 +1024,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseId: "123", topicId: "", graded: false, + due: Date(), completion: 0, type: .video, displayName: "", @@ -1031,7 +1057,9 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .chapter, completion: 0, - childs: [vertical] + childs: [vertical], + sequentialProgress: nil, + due: Date() ) let chapter = CourseChapter( @@ -1058,7 +1086,8 @@ final class CourseContainerViewModelTests: XCTestCase { )), certificate: nil, org: "", - isSelfPaced: true + isSelfPaced: true, + courseProgress: nil ) let downloadData = DownloadDataTask( @@ -1129,6 +1158,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseId: "123", topicId: "", graded: false, + due: Date(), completion: 0, type: .video, displayName: "", @@ -1150,6 +1180,7 @@ final class CourseContainerViewModelTests: XCTestCase { courseId: "123", topicId: "", graded: false, + due: Date(), completion: 0, type: .video, displayName: "", @@ -1182,7 +1213,9 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .chapter, completion: 0, - childs: [vertical] + childs: [vertical], + sequentialProgress: nil, + due: Date() ) let chapter = CourseChapter( @@ -1209,7 +1242,8 @@ final class CourseContainerViewModelTests: XCTestCase { )), certificate: nil, org: "", - isSelfPaced: true + isSelfPaced: true, + courseProgress: nil ) let downloadData = DownloadDataTask( diff --git a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift index 74d006cbc..19ae08286 100644 --- a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift @@ -46,7 +46,8 @@ final class CourseDateViewModelTests: XCTestCase { large: "")), certificate: nil, org: "", - isSelfPaced: true + isSelfPaced: true, + courseProgress: nil ) Given(interactor, .getCourseDates(courseID: .any, willReturn: courseDates)) diff --git a/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift index 1e206e81e..abf7d2000 100644 --- a/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift @@ -20,7 +20,8 @@ final class CourseUnitViewModelTests: XCTestCase { id: "1", courseId: "123", topicId: "1", - graded: false, + graded: false, + due: Date(), completion: 0, type: .video, displayName: "Lesson 1", @@ -34,6 +35,7 @@ final class CourseUnitViewModelTests: XCTestCase { courseId: "123", topicId: "2", graded: false, + due: Date(), completion: 0, type: .video, displayName: "Lesson 2", @@ -47,6 +49,7 @@ final class CourseUnitViewModelTests: XCTestCase { courseId: "123", topicId: "3", graded: false, + due: Date(), completion: 0, type: .unknown, displayName: "Lesson 3", @@ -60,6 +63,7 @@ final class CourseUnitViewModelTests: XCTestCase { courseId: "123", topicId: "4", graded: false, + due: Date(), completion: 0, type: .unknown, displayName: "4", @@ -90,7 +94,10 @@ final class CourseUnitViewModelTests: XCTestCase { type: .vertical, completion: 0, childs: blocks) - ]) + ], + sequentialProgress: nil, + due: Date() + ) ]), CourseChapter( @@ -112,7 +119,10 @@ final class CourseUnitViewModelTests: XCTestCase { type: .vertical, completion: 0, childs: blocks) - ]) + ], + sequentialProgress: nil, + due: Date() + ) ]) ] diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 12ee2d514..6b722f8c2 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -203,11 +203,12 @@ class AppAssembly: Assembly { }.inObjectScope(.container) container.register(PipManagerProtocol.self) { r in - PipManager( + let config = r.resolve(ConfigProtocol.self)! + return PipManager( router: r.resolve(Router.self)!, discoveryInteractor: r.resolve(DiscoveryInteractorProtocol.self)!, courseInteractor: r.resolve(CourseInteractorProtocol.self)!, - isNestedListEnabled: r.resolve(ConfigProtocol.self)?.uiComponents.courseNestedListEnabled ?? false + courseDropDownNavigationEnabled: config.uiComponents.courseDropDownNavigationEnabled ) }.inObjectScope(.container) } diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 44b421731..04ddc0514 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -352,7 +352,7 @@ class ScreenAssembly: Assembly { container.register( YouTubeVideoPlayerViewModel.self - ) { (r, url: URL?, blockID: String, courseID: String, languages, playerStateSubject) in + ) { (r, url: URL?, blockID: String, courseID: String, languages: [SubtitleUrl], playerStateSubject: CurrentValueSubject) in let router: Router = r.resolve(Router.self)! return YouTubeVideoPlayerViewModel( languages: languages, diff --git a/OpenEdX/Data/CoursePersistence.swift b/OpenEdX/Data/CoursePersistence.swift index 74005ca53..94ded4e9b 100644 --- a/OpenEdX/Data/CoursePersistence.swift +++ b/OpenEdX/Data/CoursePersistence.swift @@ -75,7 +75,7 @@ public class CoursePersistence: CoursePersistenceProtocol { let requestBlocks = CDCourseBlock.fetchRequest() requestBlocks.predicate = NSPredicate(format: "courseID = %@", courseID) - + let blocks = try? context.fetch(requestBlocks).map { let userViewData = DataLayer.CourseDetailUserViewData( transcripts: $0.transcripts?.jsonStringToDictionary() as? [String: String], @@ -111,6 +111,7 @@ public class CoursePersistence: CoursePersistenceProtocol { blockId: $0.blockId ?? "", id: $0.id ?? "", graded: $0.graded, + due: $0.due, completion: $0.completion, studentUrl: $0.studentUrl ?? "", webUrl: $0.webUrl ?? "", @@ -119,7 +120,12 @@ public class CoursePersistence: CoursePersistenceProtocol { descendants: $0.descendants, allSources: $0.allSources, userViewData: userViewData, - multiDevice: $0.multiDevice + multiDevice: $0.multiDevice, + assignmentProgress: DataLayer.AssignmentProgress( + assignmentType: $0.assignmentType, + numPointsEarned: $0.numPointsEarned, + numPointsPossible: $0.numPointsPossible + ) ) } @@ -140,7 +146,11 @@ public class CoursePersistence: CoursePersistenceProtocol { ), certificate: DataLayer.Certificate(url: structure.certificate), org: structure.org ?? "", - isSelfPaced: structure.isSelfPaced + isSelfPaced: structure.isSelfPaced, + courseProgress: DataLayer.CourseProgress( + assignmentsCompleted: Int(structure.assignmentsCompleted), + totalAssignmentsCount: Int(structure.totalAssignmentsCount) + ) ) } @@ -155,6 +165,8 @@ public class CoursePersistence: CoursePersistenceProtocol { newStructure.id = structure.id newStructure.rootItem = structure.rootItem newStructure.isSelfPaced = structure.isSelfPaced + newStructure.totalAssignmentsCount = Int32(structure.courseProgress?.totalAssignmentsCount ?? 0) + newStructure.assignmentsCompleted = Int32(structure.courseProgress?.assignmentsCompleted ?? 0) for block in Array(structure.dict.values) { let courseDetail = CDCourseBlock(context: self.context) @@ -169,6 +181,18 @@ public class CoursePersistence: CoursePersistenceProtocol { courseDetail.type = block.type courseDetail.completion = block.completion ?? 0 courseDetail.multiDevice = block.multiDevice ?? false + if let numPointsEarned = block.assignmentProgress?.numPointsEarned { + courseDetail.numPointsEarned = numPointsEarned + } + if let numPointsPossible = block.assignmentProgress?.numPointsPossible { + courseDetail.numPointsPossible = numPointsPossible + } + if let assignmentType = block.assignmentProgress?.assignmentType { + courseDetail.assignmentType = assignmentType + } + if let due = block.due { + courseDetail.due = due + } if block.userViewData?.encodedVideo?.youTube != nil { let youTube = CDCourseBlockVideo(context: self.context) diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift index 6de11a104..94895077d 100644 --- a/OpenEdX/Managers/PipManager.swift +++ b/OpenEdX/Managers/PipManager.swift @@ -16,7 +16,7 @@ public class PipManager: PipManagerProtocol { let discoveryInteractor: DiscoveryInteractorProtocol let courseInteractor: CourseInteractorProtocol let router: Router - let isNestedListEnabled: Bool + let courseDropDownNavigationEnabled: Bool public var isPipActive: Bool { controllerHolder != nil } @@ -28,12 +28,12 @@ public class PipManager: PipManagerProtocol { router: Router, discoveryInteractor: DiscoveryInteractorProtocol, courseInteractor: CourseInteractorProtocol, - isNestedListEnabled: Bool + courseDropDownNavigationEnabled: Bool ) { self.discoveryInteractor = discoveryInteractor self.courseInteractor = courseInteractor self.router = router - self.isNestedListEnabled = isNestedListEnabled + self.courseDropDownNavigationEnabled = courseDropDownNavigationEnabled } public func holder( @@ -114,7 +114,7 @@ public class PipManager: PipManagerProtocol { viewControllers.append(try await containerController(for: holder)) } - if !isNestedListEnabled && holder.selectedCourseTab != CourseTab.dates.rawValue { + if !courseDropDownNavigationEnabled && holder.selectedCourseTab != CourseTab.dates.rawValue { viewControllers.append(try await courseVerticalController(for: holder)) } diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index f14818890..8dbbafa20 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -485,7 +485,7 @@ public class Router: AuthorizationRouter, )! let config = Container.shared.resolve(ConfigProtocol.self) - let isDropdownActive = config?.uiComponents.courseNestedListEnabled ?? false + let isDropdownActive = config?.uiComponents.courseDropDownNavigationEnabled ?? false let view = CourseUnitView(viewModel: viewModel, isDropdownActive: isDropdownActive) return UIHostingController(rootView: view) @@ -590,13 +590,12 @@ public class Router: AuthorizationRouter, chapterIndex: chapterIndex, sequentialIndex: sequentialIndex ) - - let config = Container.shared.resolve(ConfigProtocol.self) - let isCourseNestedListEnabled = config?.uiComponents.courseNestedListEnabled ?? false var controllers = navigationController.viewControllers + let config = Container.shared.resolve(ConfigProtocol.self)! + let courseDropDownNavigationEnabled = config.uiComponents.courseDropDownNavigationEnabled - if isCourseNestedListEnabled || currentCourseTabSelection == CourseTab.dates.rawValue { + if courseDropDownNavigationEnabled || currentCourseTabSelection == CourseTab.dates.rawValue { controllers.removeLast(1) controllers.append(contentsOf: [controllerUnit]) } else { diff --git a/Theme/Theme/Assets.xcassets/Colors/CardView/CardViewBackground.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/CardView/CardViewBackground.colorset/Contents.json index 164b36790..44092279d 100644 --- a/Theme/Theme/Assets.xcassets/Colors/CardView/CardViewBackground.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/CardView/CardViewBackground.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "display-p3", "components" : { "alpha" : "1.000", - "blue" : "1.000", - "green" : "1.000", - "red" : "1.000" + "blue" : "0xFF", + "green" : "0xFF", + "red" : "0xFF" } }, "idiom" : "universal" diff --git a/Theme/Theme/Assets.xcassets/Colors/InfoColor.colorset copy/Contents.json b/Theme/Theme/Assets.xcassets/Colors/InfoColor.colorset copy/Contents.json deleted file mode 100644 index 00d59cb46..000000000 --- a/Theme/Theme/Assets.xcassets/Colors/InfoColor.colorset copy/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/IrreversibleAlert.colorset copy/Contents.json b/Theme/Theme/Assets.xcassets/Colors/IrreversibleAlert.colorset copy/Contents.json deleted file mode 100644 index 14e0c379b..000000000 --- a/Theme/Theme/Assets.xcassets/Colors/IrreversibleAlert.colorset copy/Contents.json +++ /dev/null @@ -1,38 +0,0 @@ -{ - "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/TabbarColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/TabbarColor.colorset/Contents.json index 2d9b9cd70..7e4772ec9 100644 --- a/Theme/Theme/Assets.xcassets/Colors/TabbarColor.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/TabbarColor.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "display-p3", "components" : { "alpha" : "1.000", - "blue" : "0.984", - "green" : "0.980", - "red" : "0.976" + "blue" : "0xFA", + "green" : "0xF9", + "red" : "0xF8" } }, "idiom" : "universal" From c39b0879be928b8164e5a5686b69de17d711f861 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Tue, 11 Jun 2024 10:44:22 +0200 Subject: [PATCH 13/55] fix: delete local file first since we build url with data from DB (#453) Co-authored-by: Anton Yarmolenko <37253+rnr@users.noreply.github.com> --- Core/Core/Network/DownloadManager.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index 15ec5fd10..1ef8bcb3a 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -274,11 +274,11 @@ public class DownloadManager: DownloadManagerProtocol { public func deleteFile(blocks: [CourseBlock]) async { for block in blocks { do { - try persistence.deleteDownloadDataTask(id: block.id) - currentDownloadEventPublisher.send(.deletedFile(block.id)) if let fileURL = await fileUrl(for: block.id) { try FileManager.default.removeItem(at: fileURL) } + try persistence.deleteDownloadDataTask(id: block.id) + currentDownloadEventPublisher.send(.deletedFile(block.id)) } catch { debugLog("Error deleting file: \(error.localizedDescription)") } From 5ef90cf38bd76f1b9dabef60763e6ca1c800971c Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko <37253+rnr@users.noreply.github.com> Date: Tue, 11 Jun 2024 12:01:51 +0200 Subject: [PATCH 14/55] chore: added isModal flag --- .../View/Base/VideoDownloadQualityView.swift | 77 ++++++++++++------- .../Outline/CourseOutlineView.swift | 3 +- .../VideoDownloadQualityContainerView.swift | 8 +- 3 files changed, 57 insertions(+), 31 deletions(-) diff --git a/Core/Core/View/Base/VideoDownloadQualityView.swift b/Core/Core/View/Base/VideoDownloadQualityView.swift index 06e3f7c5a..7317499e4 100644 --- a/Core/Core/View/Base/VideoDownloadQualityView.swift +++ b/Core/Core/View/Base/VideoDownloadQualityView.swift @@ -34,13 +34,15 @@ public struct VideoDownloadQualityView: View { private var viewModel: VideoDownloadQualityViewModel private var analytics: CoreAnalytics private var router: BaseRouter + private var isModal: Bool @Environment (\.isHorizontal) private var isHorizontal public init( downloadQuality: DownloadQuality, didSelect: ((DownloadQuality) -> Void)?, analytics: CoreAnalytics, - router: BaseRouter + router: BaseRouter, + isModal: Bool = false ) { self._viewModel = StateObject( wrappedValue: .init( @@ -50,41 +52,46 @@ public struct VideoDownloadQualityView: View { ) self.analytics = analytics self.router = router + self.isModal = isModal } public var body: some View { GeometryReader { proxy in ZStack(alignment: .top) { - VStack { - ThemeAssets.headerBackground.swiftUIImage - .resizable() - .edgesIgnoringSafeArea(.top) + if !isModal { + VStack { + ThemeAssets.headerBackground.swiftUIImage + .resizable() + .edgesIgnoringSafeArea(.top) + } + .frame(maxWidth: .infinity, maxHeight: 200) + .accessibilityIdentifier("auth_bg_image") } - .frame(maxWidth: .infinity, maxHeight: 200) - .accessibilityIdentifier("auth_bg_image") // MARK: - Page name VStack(alignment: .center) { - ZStack { - HStack { - Text(CoreLocalization.Settings.videoDownloadQualityTitle) - .titleSettings(color: Theme.Colors.loginNavigationText) - .accessibilityIdentifier("manage_account_text") + if !isModal { + ZStack { + HStack { + Text(CoreLocalization.Settings.videoDownloadQualityTitle) + .titleSettings(color: Theme.Colors.loginNavigationText) + .accessibilityIdentifier("manage_account_text") + } + VStack { + BackNavigationButton( + color: Theme.Colors.loginNavigationText, + action: { + router.back() + } + ) + .backViewStyle() + .padding(.leading, isHorizontal ? 48 : 0) + .accessibilityIdentifier("back_button") + + }.frame(minWidth: 0, + maxWidth: .infinity, + alignment: .topLeading) } - VStack { - BackNavigationButton( - color: Theme.Colors.loginNavigationText, - action: { - router.back() - } - ) - .backViewStyle() - .padding(.leading, isHorizontal ? 48 : 0) - .accessibilityIdentifier("back_button") - - }.frame(minWidth: 0, - maxWidth: .infinity, - alignment: .topLeading) } // MARK: - Page Body ScrollView { @@ -129,8 +136,8 @@ public struct VideoDownloadQualityView: View { } } } - .navigationBarHidden(true) - .navigationBarBackButtonHidden(true) + .navigationBarHidden(!isModal) + .navigationBarBackButtonHidden(!isModal) .navigationTitle(CoreLocalization.Settings.videoDownloadQualityTitle) .ignoresSafeArea(.all, edges: .horizontal) .background( @@ -210,3 +217,17 @@ public extension DownloadQuality { } } } + +#if DEBUG +struct AddTopic_Previews: PreviewProvider { + static var previews: some View { + VideoDownloadQualityView( + downloadQuality: .auto, + didSelect: nil, + analytics: CoreAnalyticsMock(), + router: BaseRouterMock(), + isModal: true + ) + } +} +#endif diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index cb7e1bec2..381decc63 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -205,7 +205,8 @@ public struct CourseOutlineView: View { downloadQuality: $0.downloadQuality, didSelect: viewModel.update(downloadQuality:), analytics: viewModel.coreAnalytics, - router: viewModel.router + router: viewModel.router, + isModal: true ) } } diff --git a/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift b/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift index 09fcf8001..79007c35e 100644 --- a/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift +++ b/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift @@ -17,17 +17,20 @@ struct VideoDownloadQualityContainerView: View { private var didSelect: ((DownloadQuality) -> Void)? private let analytics: CoreAnalytics private let router: CourseRouter + private var isModal: Bool init( downloadQuality: DownloadQuality, didSelect: ((DownloadQuality) -> Void)?, analytics: CoreAnalytics, - router: CourseRouter + router: CourseRouter, + isModal: Bool = false ) { self.downloadQuality = downloadQuality self.didSelect = didSelect self.analytics = analytics self.router = router + self.isModal = isModal } var body: some View { @@ -36,7 +39,8 @@ struct VideoDownloadQualityContainerView: View { downloadQuality: downloadQuality, didSelect: didSelect, analytics: analytics, - router: router + router: router, + isModal: isModal ) .navigationBarTitleDisplayMode(.inline) .toolbar { From 3ab245b8961e08c51dd431139d2950152e330a93 Mon Sep 17 00:00:00 2001 From: Shafqat Muneer Date: Wed, 12 Jun 2024 14:24:23 +0500 Subject: [PATCH 15/55] feat: Delete old downloaded videos data on device to optimize storage (#452) * feat: Delete old downloaded videos data on device to optimize storage * chore: resolved failed test cases * chore: address review feedback --- .../AuthorizationMock.generated.swift | 15 +++++ Core/Core/Data/CoreStorage.swift | 2 + Core/Core/Network/DownloadManager.swift | 59 +++++++++++++++++++ Course/CourseTests/CourseMock.generated.swift | 15 +++++ .../DashboardMock.generated.swift | 15 +++++ .../DiscoveryMock.generated.swift | 15 +++++ .../DiscussionMock.generated.swift | 15 +++++ OpenEdX/Data/AppStorage.swift | 16 ++++- OpenEdX/RouteController.swift | 20 +++++++ .../ProfileTests/ProfileMock.generated.swift | 15 +++++ 10 files changed, 186 insertions(+), 1 deletion(-) diff --git a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift index e1c3b12b5..f17939bd4 100644 --- a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift +++ b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift @@ -2405,6 +2405,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func removeAppSupportDirectoryUnusedContent() { + addInvocation(.m_removeAppSupportDirectoryUnusedContent) + let perform = methodPerformValue(.m_removeAppSupportDirectoryUnusedContent) as? () -> Void + perform?() + } + fileprivate enum MethodType { case m_publisher @@ -2421,6 +2427,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_fileUrl__for_blockId(Parameter) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) + case m_removeAppSupportDirectoryUnusedContent case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -2477,6 +2484,8 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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_removeAppSupportDirectoryUnusedContent, .m_removeAppSupportDirectoryUnusedContent): return .match case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } @@ -2498,6 +2507,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case let .m_fileUrl__for_blockId(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue + case .m_removeAppSupportDirectoryUnusedContent: return 0 case .p_currentDownloadTask_get: return 0 } } @@ -2517,6 +2527,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_fileUrl__for_blockId: return ".fileUrl(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" + case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } @@ -2673,6 +2684,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} 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 func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } @@ -2722,6 +2734,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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 removeAppSupportDirectoryUnusedContent(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_removeAppSupportDirectoryUnusedContent, performs: perform) + } } public func given(_ method: Given) { diff --git a/Core/Core/Data/CoreStorage.swift b/Core/Core/Data/CoreStorage.swift index b3a8faca0..5acf16b66 100644 --- a/Core/Core/Data/CoreStorage.swift +++ b/Core/Core/Data/CoreStorage.swift @@ -17,6 +17,7 @@ public protocol CoreStorage { var lastReviewDate: Date? {get set} var user: DataLayer.User? {get set} var userSettings: UserSettings? {get set} + var resetAppSupportDirectoryUserData: Bool? {get set} func clear() } @@ -31,6 +32,7 @@ public class CoreStorageMock: CoreStorage { public var lastReviewDate: Date? public var user: DataLayer.User? public var userSettings: UserSettings? + public var resetAppSupportDirectoryUserData: Bool? public func clear() {} public init() {} diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index 1ef8bcb3a..1db912da9 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -124,6 +124,8 @@ public protocol DownloadManagerProtocol { func resumeDownloading() throws func fileUrl(for blockId: String) -> URL? func isLargeVideosSize(blocks: [CourseBlock]) -> Bool + + func removeAppSupportDirectoryUnusedContent() } public enum DownloadManagerEvent { @@ -470,6 +472,60 @@ public class DownloadManager: DownloadManagerProtocol { debugLog("SaveFile Error", error.localizedDescription) } } + + public func removeAppSupportDirectoryUnusedContent() { + deleteMD5HashedFolders() + } + + private func getApplicationSupportDirectory() -> URL? { + let fileManager = FileManager.default + do { + let appSupportDirectory = try fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + return appSupportDirectory + } catch { + debugPrint("Error getting Application Support Directory: \(error)") + return nil + } + } + + private func isMD5Hash(_ folderName: String) -> Bool { + let md5Regex = "^[a-fA-F0-9]{32}$" + let predicate = NSPredicate(format: "SELF MATCHES %@", md5Regex) + return predicate.evaluate(with: folderName) + } + + private func deleteMD5HashedFolders() { + guard let appSupportDirectory = getApplicationSupportDirectory() else { + return + } + + let fileManager = FileManager.default + do { + let folderContents = try fileManager.contentsOfDirectory( + at: appSupportDirectory, + includingPropertiesForKeys: nil, + options: [] + ) + for folderURL in folderContents { + let folderName = folderURL.lastPathComponent + if isMD5Hash(folderName) { + do { + try fileManager.removeItem(at: folderURL) + debugPrint("Deleted folder: \(folderName)") + } catch { + debugPrint("Error deleting folder \(folderName): \(error)") + } + } + } + } catch { + debugPrint("Error reading contents of Application Support directory: \(error)") + } + } } @available(iOSApplicationExtension, unavailable) @@ -638,6 +694,9 @@ public class DownloadManagerMock: DownloadManagerProtocol { false } + public func removeAppSupportDirectoryUnusedContent() { + + } } #endif // swiftlint:enable file_length diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index 1b68b62f7..855432bc8 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -2831,6 +2831,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func removeAppSupportDirectoryUnusedContent() { + addInvocation(.m_removeAppSupportDirectoryUnusedContent) + let perform = methodPerformValue(.m_removeAppSupportDirectoryUnusedContent) as? () -> Void + perform?() + } + fileprivate enum MethodType { case m_publisher @@ -2847,6 +2853,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_fileUrl__for_blockId(Parameter) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) + case m_removeAppSupportDirectoryUnusedContent case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -2903,6 +2910,8 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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_removeAppSupportDirectoryUnusedContent, .m_removeAppSupportDirectoryUnusedContent): return .match case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } @@ -2924,6 +2933,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case let .m_fileUrl__for_blockId(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue + case .m_removeAppSupportDirectoryUnusedContent: return 0 case .p_currentDownloadTask_get: return 0 } } @@ -2943,6 +2953,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_fileUrl__for_blockId: return ".fileUrl(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" + case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } @@ -3099,6 +3110,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} 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 func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } @@ -3148,6 +3160,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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 removeAppSupportDirectoryUnusedContent(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_removeAppSupportDirectoryUnusedContent, performs: perform) + } } public func given(_ method: Given) { diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index 27620fef4..5dd6af2cc 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -2144,6 +2144,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func removeAppSupportDirectoryUnusedContent() { + addInvocation(.m_removeAppSupportDirectoryUnusedContent) + let perform = methodPerformValue(.m_removeAppSupportDirectoryUnusedContent) as? () -> Void + perform?() + } + fileprivate enum MethodType { case m_publisher @@ -2160,6 +2166,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_fileUrl__for_blockId(Parameter) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) + case m_removeAppSupportDirectoryUnusedContent case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -2216,6 +2223,8 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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_removeAppSupportDirectoryUnusedContent, .m_removeAppSupportDirectoryUnusedContent): return .match case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } @@ -2237,6 +2246,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case let .m_fileUrl__for_blockId(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue + case .m_removeAppSupportDirectoryUnusedContent: return 0 case .p_currentDownloadTask_get: return 0 } } @@ -2256,6 +2266,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_fileUrl__for_blockId: return ".fileUrl(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" + case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } @@ -2412,6 +2423,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} 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 func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } @@ -2461,6 +2473,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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 removeAppSupportDirectoryUnusedContent(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_removeAppSupportDirectoryUnusedContent, performs: perform) + } } public func given(_ method: Given) { diff --git a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index 28f8eba5a..7b7d2c10a 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -2338,6 +2338,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func removeAppSupportDirectoryUnusedContent() { + addInvocation(.m_removeAppSupportDirectoryUnusedContent) + let perform = methodPerformValue(.m_removeAppSupportDirectoryUnusedContent) as? () -> Void + perform?() + } + fileprivate enum MethodType { case m_publisher @@ -2354,6 +2360,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_fileUrl__for_blockId(Parameter) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) + case m_removeAppSupportDirectoryUnusedContent case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -2410,6 +2417,8 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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_removeAppSupportDirectoryUnusedContent, .m_removeAppSupportDirectoryUnusedContent): return .match case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } @@ -2431,6 +2440,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case let .m_fileUrl__for_blockId(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue + case .m_removeAppSupportDirectoryUnusedContent: return 0 case .p_currentDownloadTask_get: return 0 } } @@ -2450,6 +2460,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_fileUrl__for_blockId: return ".fileUrl(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" + case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } @@ -2606,6 +2617,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} 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 func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } @@ -2655,6 +2667,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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 removeAppSupportDirectoryUnusedContent(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_removeAppSupportDirectoryUnusedContent, performs: perform) + } } public func given(_ method: Given) { diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index d8324dd64..85aa084a5 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -3275,6 +3275,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func removeAppSupportDirectoryUnusedContent() { + addInvocation(.m_removeAppSupportDirectoryUnusedContent) + let perform = methodPerformValue(.m_removeAppSupportDirectoryUnusedContent) as? () -> Void + perform?() + } + fileprivate enum MethodType { case m_publisher @@ -3291,6 +3297,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_fileUrl__for_blockId(Parameter) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) + case m_removeAppSupportDirectoryUnusedContent case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -3347,6 +3354,8 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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_removeAppSupportDirectoryUnusedContent, .m_removeAppSupportDirectoryUnusedContent): return .match case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } @@ -3368,6 +3377,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case let .m_fileUrl__for_blockId(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue + case .m_removeAppSupportDirectoryUnusedContent: return 0 case .p_currentDownloadTask_get: return 0 } } @@ -3387,6 +3397,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_fileUrl__for_blockId: return ".fileUrl(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" + case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } @@ -3543,6 +3554,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} 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 func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } @@ -3592,6 +3604,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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 removeAppSupportDirectoryUnusedContent(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_removeAppSupportDirectoryUnusedContent, performs: perform) + } } public func given(_ method: Given) { diff --git a/OpenEdX/Data/AppStorage.swift b/OpenEdX/Data/AppStorage.swift index ff17e4128..e21a94513 100644 --- a/OpenEdX/Data/AppStorage.swift +++ b/OpenEdX/Data/AppStorage.swift @@ -203,7 +203,20 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto } } } - + + public var resetAppSupportDirectoryUserData: Bool? { + get { + return userDefaults.bool(forKey: KEY_RESET_APP_SUPPORT_DIRECTORY_USER_DATA) + } + set(newValue) { + if let newValue { + userDefaults.set(newValue, forKey: KEY_RESET_APP_SUPPORT_DIRECTORY_USER_DATA) + } else { + userDefaults.removeObject(forKey: KEY_RESET_APP_SUPPORT_DIRECTORY_USER_DATA) + } + } + } + public func clear() { accessToken = nil refreshToken = nil @@ -223,4 +236,5 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto private let KEY_APPLE_SIGN_FULLNAME = "appleSignFullName" private let KEY_APPLE_SIGN_EMAIL = "appleSignEmail" private let KEY_ALLOWED_DOWNLOAD_LARGE_FILE = "allowedDownloadLargeFile" + private let KEY_RESET_APP_SUPPORT_DIRECTORY_USER_DATA = "resetAppSupportDirectoryUserData" } diff --git a/OpenEdX/RouteController.swift b/OpenEdX/RouteController.swift index ac53c3d36..6df0b22c0 100644 --- a/OpenEdX/RouteController.swift +++ b/OpenEdX/RouteController.swift @@ -44,6 +44,7 @@ class RouteController: UIViewController { } } + resetAppSupportDirectoryUserData() coreAnalytics.trackEvent(.launch, biValue: .launch) } @@ -99,4 +100,23 @@ class RouteController: UIViewController { } present(navigation, animated: false) } + + /** + This code will delete any old application’s downloaded user data, such as video files, + from the Application Support directory to optimize storage. This activity will be performed + only once during the upgrade from the old Open edX application to the new one or during + fresh installation. We can consider removing this code once we are confident that most or + all users have transitioned to the new application. + */ + private func resetAppSupportDirectoryUserData() { + guard var upgradationValue = Container.shared.resolve(CoreStorage.self), + let downloadManager = Container.shared.resolve(DownloadManagerProtocol.self), + upgradationValue.resetAppSupportDirectoryUserData == false + else { return } + + Task { + downloadManager.removeAppSupportDirectoryUnusedContent() + upgradationValue.resetAppSupportDirectoryUserData = true + } + } } diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 1a3cd757b..35a5cae13 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -1612,6 +1612,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func removeAppSupportDirectoryUnusedContent() { + addInvocation(.m_removeAppSupportDirectoryUnusedContent) + let perform = methodPerformValue(.m_removeAppSupportDirectoryUnusedContent) as? () -> Void + perform?() + } + fileprivate enum MethodType { case m_publisher @@ -1628,6 +1634,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_fileUrl__for_blockId(Parameter) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) + case m_removeAppSupportDirectoryUnusedContent case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -1684,6 +1691,8 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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_removeAppSupportDirectoryUnusedContent, .m_removeAppSupportDirectoryUnusedContent): return .match case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } @@ -1705,6 +1714,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case let .m_fileUrl__for_blockId(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue + case .m_removeAppSupportDirectoryUnusedContent: return 0 case .p_currentDownloadTask_get: return 0 } } @@ -1724,6 +1734,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_fileUrl__for_blockId: return ".fileUrl(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" + case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } @@ -1880,6 +1891,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} 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 func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } @@ -1929,6 +1941,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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 removeAppSupportDirectoryUnusedContent(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_removeAppSupportDirectoryUnusedContent, performs: perform) + } } public func given(_ method: Given) { From dc32ffacd111fe35ffbd5382ed8045a32b87e1dc Mon Sep 17 00:00:00 2001 From: forgotvas Date: Wed, 12 Jun 2024 19:29:19 +0300 Subject: [PATCH 16/55] fix: double loading indicator --- Core/Core/View/Base/ProgressBar.swift | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Core/Core/View/Base/ProgressBar.swift b/Core/Core/View/Base/ProgressBar.swift index 7bc6e5195..b3be390e7 100644 --- a/Core/Core/View/Base/ProgressBar.swift +++ b/Core/Core/View/Base/ProgressBar.swift @@ -40,17 +40,18 @@ public struct ProgressBar: View { Circle() .stroke(lineWidth: lineWidth) .foregroundColor(Theme.Colors.accentColor.opacity(0.3)) - .frame(width: size, height: size) Circle() .trim(from: 0.0, to: 0.7) .stroke(gradient, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) - .frame(width: size, height: size) - .rotationEffect(Angle.degrees(isAnimating ? 360 : 0), anchor: .center) - .animation(animation, value: isAnimating) } + .frame(width: size, height: size) + .rotationEffect(Angle.degrees(isAnimating ? 360 : 0), anchor: .center) + .animation(animation, value: isAnimating) .onAppear { - isAnimating = true + DispatchQueue.main.async { + isAnimating = true + } } } } From dcb41346cf54d19e185003f4c9a7577dce9f0cd8 Mon Sep 17 00:00:00 2001 From: Amr Nashawaty Date: Wed, 12 Jun 2024 22:23:22 +0300 Subject: [PATCH 17/55] feat: atlas push pull scripts: FC-55 (#422) --- .gitignore | 8 +- Makefile | 12 ++ README.md | 41 +++++ i18n_scripts/requirements.txt | 3 + i18n_scripts/translation.py | 309 ++++++++++++++++++++++++++++++++++ 5 files changed, 372 insertions(+), 1 deletion(-) create mode 100644 Makefile create mode 100644 i18n_scripts/requirements.txt create mode 100644 i18n_scripts/translation.py diff --git a/.gitignore b/.gitignore index 8a80e27f8..274b62ec6 100644 --- a/.gitignore +++ b/.gitignore @@ -115,4 +115,10 @@ vendor/ venv/ Podfile.lock config_settings.yaml -default_config/ \ No newline at end of file +default_config/ + +# Translations ignored files +.venv/ +I18N/ +*.lproj/ +!en.lproj/ diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..5f97f7c59 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +clean_translations_temp_directory: + rm -rf I18N/ + +translation_requirements: + pip3 install -r i18n_scripts/requirements.txt + +pull_translations: clean_translations_temp_directory + atlas pull $(ATLAS_OPTIONS) translations/openedx-app-ios/I18N:I18N + python3 i18n_scripts/translation.py --split --replace-underscore + +extract_translations: clean_translations_temp_directory + python3 i18n_scripts/translation.py --combine diff --git a/README.md b/README.md index 828a971a4..bb4b9b578 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,47 @@ Modern vision of the mobile application for the Open edX platform from Raccoon G 6. Click the **Run** button. +## Translations +### Getting translations for the app +Translations aren't included in the source code of this repository as of [OEP-58](https://docs.openedx.org/en/latest/developers/concepts/oep58.html). Therefore, they need to be pulled before testing or publishing to App Store. + +Before retrieving the translations for the app, we need to install the requirements listed in the requirements.txt file located in the i18n_scripts directory. This can be done easily by running the following make command: +```bash +make translation_requirements +``` + +Then, to get the latest translations for all languages use the following command: +```bash +make pull_translations +``` +This command runs [`atlas pull`](https://github.com/openedx/openedx-atlas) to download the latest translations files from the [openedx/openedx-translations](https://github.com/openedx/openedx-translations) repository. These files contain the latest translations for all languages. In the [openedx/openedx-translations](https://github.com/openedx/openedx-translations) repository each language's translations are saved as a single file e.g. `I18N/I18N/uk.lproj/Localization.strings` ([example](https://github.com/openedx/openedx-translations/blob/6448167e9695a921f003ff6bd8f40f006a2d6743/translations/openedx-app-ios/I18N/I18N/uk.lproj/Localizable.strings)). After these are pulled, each language's translation file is split into the App's modules e.g. `Discovery/Discovery/uk.lproj/Localization.strings`. + + After this command is run the application can load the translations by changing the device (or the emulator) language in the settings. + +### Using custom translations + +By default, the command `make pull_translations` runs [`atlas pull`](https://github.com/openedx/openedx-atlas) with no arguments which pulls transaltions from the [openedx-translations repository](https://github.com/openedx/openedx-translations). + +You can use custom translations on your fork of the openedx-translations repository by setting the following configuration parameters: + +- `--revision` (default: `"main"`): Branch or git tag to pull translations from. +- `--repository` (default: `"openedx/openedx-translations"`): GitHub repository slug. There's a feature request to [support GitLab and other providers](https://github.com/openedx/openedx-atlas/issues/20). + +Arguments can be passed via the `ATLAS_OPTIONS` environment variable as shown below: +``` bash +make ATLAS_OPTIONS='--repository=/ --revision=' pull_translations +``` +Additional arguments can be passed to `atlas pull`. Refer to the [atlas documentations ](https://github.com/openedx/openedx-atlas) for more information. + +### How to translate the app + +Translations are managed in the [open-edx/openedx-translations](https://app.transifex.com/open-edx/openedx-translations/dashboard/) Transifex project. + +To translate the app join the [Transifex project](https://app.transifex.com/open-edx/openedx-translations/dashboard/) and add your translations `openedx-app-ios` resource: https://app.transifex.com/open-edx/openedx-translations/openedx-app-ios/ (the link will start working after the [pull request #442](https://github.com/openedx/openedx-app-ios/pull/422) is merged) + +Once the resource is both 100% translated and reviewed the [Transifex integration](https://github.com/apps/transifex-integration) will automatically push it to the [openedx-translations](https://github.com/openedx/openedx-translations) repository and developers can use the translations in their app. + + ## API This project targets on the latest Open edX release and rely on the relevant mobile APIs. diff --git a/i18n_scripts/requirements.txt b/i18n_scripts/requirements.txt new file mode 100644 index 000000000..384c433ad --- /dev/null +++ b/i18n_scripts/requirements.txt @@ -0,0 +1,3 @@ +# Translation processing dependencies +openedx-atlas==0.6.1 +localizable==0.1.3 \ No newline at end of file diff --git a/i18n_scripts/translation.py b/i18n_scripts/translation.py new file mode 100644 index 000000000..5a56ca48e --- /dev/null +++ b/i18n_scripts/translation.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +""" +This script performs two jobs: + 1- Combine the English translations from all modules in the repository to the I18N directory. After the English + translation is combined, it will be pushed to the openedx-translations repository as described in OEP-58. +2- Split the pulled translation files from the openedx-translations repository into the iOS app modules. + +More detailed specifications are described in the docs/0002-atlas-translations-management.rst design doc. +""" + +import argparse +import os +import re +import sys +from collections import defaultdict +import localizable + + +def parse_arguments(): + """ + This function is the argument parser for this script. + The script takes only one of the two arguments --split or --combine. + Additionally, the --replace-underscore argument can only be used with --split. + """ + parser = argparse.ArgumentParser(description='Split or Combine translations.') + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('--split', action='store_true', + help='Split translations into separate files for each module and language.') + group.add_argument('--combine', action='store_true', + help='Combine the English translations from all modules into a single file.') + parser.add_argument('--replace-underscore', action='store_true', + help='Replace underscores with "-r" in language directories (only with --split).') + return parser.parse_args() + + +def get_translation_file_path(modules_dir, module_name, lang_dir, create_dirs=False): + """ + Retrieves the path of the translation file for a specified module and language directory. + + Parameters: + modules_dir (str): The path to the base directory containing all the modules. + module_name (str): The name of the module for which the translation path is being retrieved. + lang_dir (str): The name of the language directory within the module's directory. + create_dirs (bool): If True, creates the parent directories if they do not exist. Defaults to False. + + Returns: + str: The path to the module's translation file (Localizable.strings). + """ + try: + lang_dir_path = os.path.join(modules_dir, module_name, module_name, lang_dir, 'Localizable.strings') + if create_dirs: + os.makedirs(os.path.dirname(lang_dir_path), exist_ok=True) + return lang_dir_path + except Exception as e: + print(f"Error creating directory path: {e}", file=sys.stderr) + raise + + +def get_modules_to_translate(modules_dir): + """ + Retrieve the names of modules that have translation files for a specified language. + + Parameters: + modules_dir (str): The path to the directory containing all the modules. + + Returns: + list of str: A list of module names that have translation files for the specified language. + """ + try: + modules_list = [ + directory for directory in os.listdir(modules_dir) + if ( + os.path.isdir(os.path.join(modules_dir, directory)) + and os.path.isfile(get_translation_file_path(modules_dir, directory, 'en.lproj')) + and directory != 'I18N' + ) + ] + return modules_list + except FileNotFoundError as e: + print(f"Directory not found: {e}", file=sys.stderr) + raise + except PermissionError as e: + print(f"Permission denied: {e}", file=sys.stderr) + raise + + +def get_translations(modules_dir): + """ + Retrieve the translations from all modules in the modules_dir. + + Parameters: + modules_dir (str): The directory containing the modules. + + Returns: + dict: A dict containing a list of dictionaries containing the 'key', 'value', and 'comment' for each + translation line. The key of the outer dict is the name of the module where the translations are going + to be saved. + """ + translations = [] + try: + modules = get_modules_to_translate(modules_dir) + for module in modules: + translation_file = get_translation_file_path(modules_dir, module, lang_dir='en.lproj') + module_translation = localizable.parse_strings(filename=translation_file) + + translations += [ + { + 'key': f"{module}.{translation_entry['key']}", + 'value': translation_entry['value'], + 'comment': translation_entry['comment'] + } for translation_entry in module_translation + ] + except Exception as e: + print(f"Error retrieving translations: {e}", file=sys.stderr) + raise + + return {'I18N': translations} + + +def combine_translation_files(modules_dir=None): + """ + Combine translation files from different modules into a single file. + """ + try: + if not modules_dir: + modules_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + translation = get_translations(modules_dir) + write_translations_to_modules(modules_dir, 'en.lproj', translation) + except Exception as e: + print(f"Error combining translation files: {e}", file=sys.stderr) + raise + + +def get_languages_dirs(modules_dir): + """ + Retrieve directories containing language files for translation. + + Args: + modules_dir (str): The directory containing all the modules. + + Returns: + list: A list of directories containing language files for translation. Each directory represents + a specific language and ends with the '.lproj' extension. + """ + try: + lang_parent_dir = os.path.join(modules_dir, 'I18N', 'I18N') + languages_dirs = [ + directory for directory in os.listdir(lang_parent_dir) + if directory.endswith('.lproj') and directory != "en.lproj" + ] + return languages_dirs + except FileNotFoundError as e: + print(f"Directory not found: {e}", file=sys.stderr) + raise + except PermissionError as e: + print(f"Permission denied: {e}", file=sys.stderr) + raise + + +def get_translations_from_file(modules_dir, lang_dir): + """ + Get translations from the translation file in the 'I18N' directory and distribute them into the appropriate + modules' directories. + + Args: + modules_dir (str): The directory containing all the modules. + lang_dir (str): The directory containing the translation file being split. + + Returns: + dict: A dictionary containing translations split by module. The keys are module names, + and the values are lists of dictionaries, each containing the 'key', 'value', and 'comment' + for each translation entry within the module. + """ + translations = defaultdict(list) + try: + translations_file_path = get_translation_file_path(modules_dir, 'I18N', lang_dir) + lang_list = localizable.parse_strings(filename=translations_file_path) + for translation_entry in lang_list: + module_name, key_remainder = translation_entry['key'].split('.', maxsplit=1) + split_entry = { + 'key': key_remainder, + 'value': translation_entry['value'], + 'comment': translation_entry['comment'] + } + translations[module_name].append(split_entry) + except Exception as e: + print(f"Error extracting translations from file: {e}", file=sys.stderr) + raise + return translations + + +def write_translations_to_modules(modules_dir, lang_dir, modules_translations): + """ + Write translations to language files for each module. + + Args: + modules_dir (str): The directory containing all the modules. + lang_dir (str): The directory of the translation file being written. + modules_translations (dict): A dictionary containing translations for each module. + + Returns: + None + """ + for module, translation_list in modules_translations.items(): + try: + translation_file_path = get_translation_file_path(modules_dir, module, lang_dir, create_dirs=True) + with open(translation_file_path, 'w') as f: + for translation_entry in translation_list: + write_line_and_comment(f, translation_entry) + except Exception as e: + print(f"Error writing translations to file.\n Module: {module}\n Error: {e}", file=sys.stderr) + raise + + +def _escape(s): + """ + Reverse the replacements performed by _unescape() in the localizable library + """ + s = s.replace('\n', r'\n').replace('\r', r'\r').replace('"', r'\"') + return s + + +def write_line_and_comment(f, entry): + """ + Write a translation line with an optional comment to a file. + + Args: + file (file object): The file object to write to. + entry (dict): A dictionary containing the translation entry with 'key', 'value', and optional 'comment'. + + Returns: + None + """ + comment = entry.get('comment') # Retrieve the comment, if present + if comment: + f.write(f"/* {comment} */\n") + f.write(f'"{entry["key"]}" = "{_escape(entry["value"])}";\n') + + +def split_translation_files(modules_dir=None): + """ + Split translation files into separate files for each module and language. + + Args: + modules_dir (str, optional): The directory containing all the modules. If not provided, + it defaults to the parent directory of the directory containing this script. + + Returns: + None + """ + try: + if not modules_dir: + modules_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + languages_dirs = get_languages_dirs(modules_dir) + for lang_dir in languages_dirs: + translations = get_translations_from_file(modules_dir, lang_dir) + write_translations_to_modules(modules_dir, lang_dir, translations) + except Exception as e: + print(f"Error splitting translation files: {e}", file=sys.stderr) + raise + + +def replace_underscores(modules_dir=None): + try: + if not modules_dir: + modules_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + languages_dirs = get_languages_dirs(modules_dir) + + for lang_dir in languages_dirs: + try: + pattern = r'_(\w\w.lproj$)' + if re.search(pattern, lang_dir): + replacement = r'-\1' + new_name = re.sub(pattern, replacement, lang_dir) + lang_old_path = os.path.dirname(get_translation_file_path(modules_dir, 'I18N', lang_dir)) + lang_new_path = os.path.dirname(get_translation_file_path(modules_dir, 'I18N', new_name)) + + os.rename(lang_old_path, lang_new_path) + print(f"Renamed {lang_old_path} to {lang_new_path}") + + except FileNotFoundError as e: + print(f"Error: The file or directory {lang_old_path} does not exist: {e}", file=sys.stderr) + raise + except PermissionError as e: + print(f"Error: Permission denied while renaming {lang_old_path}: {e}", file=sys.stderr) + raise + except Exception as e: + print(f"Error: An unexpected error occurred while renaming {lang_old_path} to {lang_new_path}: {e}", + file=sys.stderr) + raise + + except Exception as e: + print(f"Error: An unexpected error occurred in rename_translations_files: {e}", file=sys.stderr) + raise + + +def main(): + args = parse_arguments() + if args.split: + if args.replace_underscore: + replace_underscores() + split_translation_files() + elif args.combine: + combine_translation_files() + + +if __name__ == "__main__": + main() From 3244d1e54e4cb8409dedf5620ffe0b6e7b9ef77f Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko <37253+rnr@users.noreply.github.com> Date: Thu, 13 Jun 2024 08:45:20 +0200 Subject: [PATCH 18/55] chore: fixed wrong name --- Core/Core/View/Base/VideoDownloadQualityView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Core/Core/View/Base/VideoDownloadQualityView.swift b/Core/Core/View/Base/VideoDownloadQualityView.swift index 7317499e4..26cce95c4 100644 --- a/Core/Core/View/Base/VideoDownloadQualityView.swift +++ b/Core/Core/View/Base/VideoDownloadQualityView.swift @@ -219,7 +219,7 @@ public extension DownloadQuality { } #if DEBUG -struct AddTopic_Previews: PreviewProvider { +struct VideoDownloadQualityView_Previews: PreviewProvider { static var previews: some View { VideoDownloadQualityView( downloadQuality: .auto, From 34cee0dce0192a6ebe9cf1ce004b0f0e085ae2f4 Mon Sep 17 00:00:00 2001 From: Vadim Kuznetsov Date: Tue, 18 Jun 2024 12:56:36 +0300 Subject: [PATCH 19/55] fix: download loading indicator (#464) --- Course/Course/Presentation/Downloads/DownloadsView.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Course/Course/Presentation/Downloads/DownloadsView.swift b/Course/Course/Presentation/Downloads/DownloadsView.swift index 791791204..93149bd4f 100644 --- a/Course/Course/Presentation/Downloads/DownloadsView.swift +++ b/Course/Course/Presentation/Downloads/DownloadsView.swift @@ -126,6 +126,7 @@ public struct DownloadsView: View { } } label: { DownloadProgressView() + .id("cirle loading indicator " + task.id) .accessibilityElement(children: .ignore) .accessibilityLabel(CourseLocalization.Accessibility.cancelDownload) .accessibilityIdentifier("cancel_download_button") From 5498a1c96744c12024cc760766cc74f1c725ec2c Mon Sep 17 00:00:00 2001 From: Vadim Kuznetsov Date: Thu, 20 Jun 2024 12:55:35 +0300 Subject: [PATCH 20/55] [iOS] Prompt | Confirmation Alert Missing Before Deleting Section-Level Downloaded Videos #456 (#463) * fix: confirmation alert * chore: changed string to localization * chore: review required changes --- .../CourseVideoDownloadBarViewModel.swift | 4 ++-- .../Subviews/CustomDisclosureGroup.swift | 23 ++++++++++++++++++- Course/Course/SwiftGen/Strings.swift | 2 ++ Course/Course/en.lproj/Localizable.strings | 1 + Course/Course/uk.lproj/Localizable.strings | 1 + 5 files changed, 28 insertions(+), 3 deletions(-) diff --git a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift index 5caa8ece4..841c49c1f 100644 --- a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift +++ b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift @@ -124,7 +124,7 @@ final class CourseVideoDownloadBarViewModel: ObservableObject { func onToggle() async { if allVideosDownloaded { courseViewModel.router.presentAlert( - alertTitle: "Warning", + alertTitle: CourseLocalization.Alert.warning, alertMessage: "\(CourseLocalization.Alert.deleteAllVideos) \"\(courseStructure.displayName)\"?", positiveAction: CoreLocalization.Alert.delete, onCloseTapped: { [weak self] in @@ -145,7 +145,7 @@ final class CourseVideoDownloadBarViewModel: ObservableObject { if isOn { courseViewModel.router.presentAlert( - alertTitle: "Warning", + alertTitle: CourseLocalization.Alert.warning, alertMessage: "\(CourseLocalization.Alert.stopDownloading) \"\(courseStructure.displayName)\"", positiveAction: CoreLocalization.Alert.accept, onCloseTapped: { [weak self] in diff --git a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift index 75186dd91..10ab50625 100644 --- a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift +++ b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift @@ -50,7 +50,24 @@ struct CustomDisclosureGroup: View { let state = downloadAllButtonState(for: chapter) { Button( action: { - downloadAllSubsections(in: chapter, state: state) + switch state { + case .finished: + viewModel.router.presentAlert( + alertTitle: CourseLocalization.Alert.warning, + alertMessage: deleteMessage(for: chapter), + positiveAction: CoreLocalization.Alert.delete, + onCloseTapped: { + viewModel.router.dismiss(animated: true) + }, + okTapped: { + downloadAllSubsections(in: chapter, state: state) + viewModel.router.dismiss(animated: true) + }, + type: .deleteVideo + ) + default: + downloadAllSubsections(in: chapter, state: state) + } }, label: { switch state { case .available: @@ -175,6 +192,10 @@ struct CustomDisclosureGroup: View { } } + private func deleteMessage(for chapter: CourseChapter) -> String { + "\(CourseLocalization.Alert.deleteVideos) \"\(chapter.displayName)\"?" + } + func getAssignmentStatus(for date: Date) -> String { let calendar = Calendar.current let today = Date() diff --git a/Course/Course/SwiftGen/Strings.swift b/Course/Course/SwiftGen/Strings.swift index 1cada7b12..91e479f03 100644 --- a/Course/Course/SwiftGen/Strings.swift +++ b/Course/Course/SwiftGen/Strings.swift @@ -37,6 +37,8 @@ public enum CourseLocalization { public static let rotateDevice = CourseLocalization.tr("Localizable", "ALERT.ROTATE_DEVICE", fallback: "Rotate your device to view this video in full screen.") /// Turning off the switch will stop downloading and delete all downloaded videos for public static let stopDownloading = CourseLocalization.tr("Localizable", "ALERT.STOP_DOWNLOADING", fallback: "Turning off the switch will stop downloading and delete all downloaded videos for") + /// Warning + public static let warning = CourseLocalization.tr("Localizable", "ALERT.WARNING", fallback: "Warning") } public enum Course { /// Due Today diff --git a/Course/Course/en.lproj/Localizable.strings b/Course/Course/en.lproj/Localizable.strings index 40a4d8157..f4a37b8a4 100644 --- a/Course/Course/en.lproj/Localizable.strings +++ b/Course/Course/en.lproj/Localizable.strings @@ -34,6 +34,7 @@ "ALERT.DELETE_ALL_VIDEOS" = "Are you sure you want to delete all video(s) for"; "ALERT.DELETE_VIDEOS" = "Are you sure you want to delete video(s) for"; "ALERT.STOP_DOWNLOADING" = "Turning off the switch will stop downloading and delete all downloaded videos for"; +"ALERT.WARNING" = "Warning"; "COURSE_CONTAINER.HOME" = "Home"; "COURSE_CONTAINER.VIDEOS" = "Videos"; diff --git a/Course/Course/uk.lproj/Localizable.strings b/Course/Course/uk.lproj/Localizable.strings index abb9dc970..b5a3746f6 100644 --- a/Course/Course/uk.lproj/Localizable.strings +++ b/Course/Course/uk.lproj/Localizable.strings @@ -33,6 +33,7 @@ "ALERT.DELETE_ALL_VIDEOS" = "Are you sure you want to delete all video(s) for"; "ALERT.DELETE_VIDEOS" = "Are you sure you want to delete video(s) for"; "ALERT.STOP_DOWNLOADING" = "Turning off the switch will stop downloading and delete all downloaded videos for"; +"ALERT.WARNING" = "Warning"; "COURSE_CONTAINER.COURSE" = "Курс"; "COURSE_CONTAINER.VIDEOS" = "Всі відео"; From b2539a6f0ee00a06879f3820d438430a4346363e Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta <127732735+volodymyr-chekyrta@users.noreply.github.com> Date: Fri, 21 Jun 2024 14:45:34 +0300 Subject: [PATCH 21/55] feat: [FC-0047] FCM (#461) * feat: firebase cloud messaging * fix: address feedback * fix: address feedback --- .../Presentation/Login/SignInViewModel.swift | 2 + .../Registration/SignUpViewModel.swift | 3 +- Core/Core/Data/CoreStorage.swift | 2 + Core/Core/Extensions/Notification.swift | 11 ++- Core/Core/Network/RequestInterceptor.swift | 6 +- .../Presentation/ListDashboardViewModel.swift | 9 ++ .../PrimaryCourseDashboardViewModel.swift | 10 ++ .../Data/Model/Data_CommentsResponse.swift | 8 +- .../Data/Network/DiscussionEndpoint.swift | 5 +- .../Data/Network/DiscussionRepository.swift | 70 +++++--------- OpenEdX.xcodeproj/project.pbxproj | 37 +++++++ OpenEdX/AppDelegate.swift | 68 +++++++++---- OpenEdX/DI/AppAssembly.swift | 9 +- OpenEdX/Data/AppStorage.swift | 65 ++++++++----- .../Data/Network/NotificationsEndpoints.swift | 44 +++++++++ .../DeepLinkManager/DeepLinkManager.swift | 79 +++++++++++---- .../DeepLinkRouter/DeepLinkRouter.swift | 6 +- .../DeepLinkManager/Link/DeepLink.swift | 16 +++- .../FirebaseAnalyticsService.swift | 8 +- .../Listeners/BrazeListener.swift | 14 +++ .../Listeners/FCMListener.swift | 23 ++++- .../Providers/BrazeProvider.swift | 6 ++ .../Providers/FCMProvider.swift | 49 +++++++++- .../PushNotificationsManager.swift | 96 ++++++++++++++----- OpenEdX/Router.swift | 4 + .../Settings/SettingsViewModel.swift | 5 + 26 files changed, 500 insertions(+), 155 deletions(-) create mode 100644 OpenEdX/Data/Network/NotificationsEndpoints.swift diff --git a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift index 041c98ca7..ef573e047 100644 --- a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift +++ b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift @@ -82,6 +82,7 @@ public class SignInViewModel: ObservableObject { analytics.identify(id: "\(user.id)", username: user.username, email: user.email) analytics.userLogin(method: .password) router.showMainOrWhatsNewScreen(sourceScreen: sourceScreen) + NotificationCenter.default.post(name: .userAuthorized, object: nil) } catch let error { failure(error) } @@ -113,6 +114,7 @@ public class SignInViewModel: ObservableObject { analytics.identify(id: "\(user.id)", username: user.username, email: user.email) analytics.userLogin(method: authMethod) router.showMainOrWhatsNewScreen(sourceScreen: sourceScreen) + NotificationCenter.default.post(name: .userAuthorized, object: nil) } catch let error { failure(error, authMethod: authMethod) } diff --git a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift index 1f57b8c02..5c41badef 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift @@ -136,7 +136,7 @@ public class SignUpViewModel: ObservableObject { analytics.registrationSuccess(method: authMetod.analyticsValue) isShowProgress = false router.showMainOrWhatsNewScreen(sourceScreen: sourceScreen) - + NotificationCenter.default.post(name: .userAuthorized, object: nil) } catch let error { isShowProgress = false if case APIError.invalidGrant = error { @@ -193,6 +193,7 @@ public class SignUpViewModel: ObservableObject { analytics.userLogin(method: authMethod) isShowProgress = false router.showMainOrWhatsNewScreen(sourceScreen: sourceScreen) + NotificationCenter.default.post(name: .userAuthorized, object: nil) } catch { update(fullName: response.name, email: response.email) self.externalToken = response.token diff --git a/Core/Core/Data/CoreStorage.swift b/Core/Core/Data/CoreStorage.swift index 5acf16b66..60837da41 100644 --- a/Core/Core/Data/CoreStorage.swift +++ b/Core/Core/Data/CoreStorage.swift @@ -10,6 +10,7 @@ import Foundation public protocol CoreStorage { var accessToken: String? {get set} var refreshToken: String? {get set} + var pushToken: String? {get set} var appleSignFullName: String? {get set} var appleSignEmail: String? {get set} var cookiesDate: String? {get set} @@ -25,6 +26,7 @@ public protocol CoreStorage { public class CoreStorageMock: CoreStorage { public var accessToken: String? public var refreshToken: String? + public var pushToken: String? public var appleSignFullName: String? public var appleSignEmail: String? public var cookiesDate: String? diff --git a/Core/Core/Extensions/Notification.swift b/Core/Core/Extensions/Notification.swift index d8e99731b..1a4aeb1db 100644 --- a/Core/Core/Extensions/Notification.swift +++ b/Core/Core/Extensions/Notification.swift @@ -8,6 +8,8 @@ import Foundation public extension Notification.Name { + static let userAuthorized = Notification.Name("userAuthorized") + static let userLoggedOut = Notification.Name("userLoggedOut") static let onCourseEnrolled = Notification.Name("onCourseEnrolled") static let onblockCompletionRequested = Notification.Name("onblockCompletionRequested") static let onTokenRefreshFailed = Notification.Name("onTokenRefreshFailed") @@ -15,8 +17,15 @@ public extension Notification.Name { static let onAppUpgradeAccountSettingsTapped = Notification.Name("onAppUpgradeAccountSettingsTapped") static let onNewVersionAvaliable = Notification.Name("onNewVersionAvaliable") static let webviewReloadNotification = Notification.Name("webviewReloadNotification") - static let onBlockCompletion = Notification.Name.init("onBlockCompletion") + static let onBlockCompletion = Notification.Name("onBlockCompletion") static let shiftCourseDates = Notification.Name("shiftCourseDates") static let profileUpdated = Notification.Name("profileUpdated") static let getCourseDates = Notification.Name("getCourseDates") + static let refreshEnrollments = Notification.Name("refreshEnrollments") +} + +public extension Notification { + enum UserInfoKey: String { + case isForced + } } diff --git a/Core/Core/Network/RequestInterceptor.swift b/Core/Core/Network/RequestInterceptor.swift index f27b6f310..860fa070d 100644 --- a/Core/Core/Network/RequestInterceptor.swift +++ b/Core/Core/Network/RequestInterceptor.swift @@ -93,7 +93,11 @@ final public class RequestInterceptor: Alamofire.RequestInterceptor { } self.requestsToRetry.removeAll() } else { - NotificationCenter.default.post(name: .onTokenRefreshFailed, object: nil) + NotificationCenter.default.post( + name: .userLoggedOut, + object: nil, + userInfo: [Notification.UserInfoKey.isForced: true] + ) } } } diff --git a/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift b/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift index 4e79877c2..bef00add1 100644 --- a/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift +++ b/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift @@ -30,6 +30,7 @@ public class ListDashboardViewModel: ObservableObject { private let interactor: DashboardInteractorProtocol private let analytics: DashboardAnalytics private var onCourseEnrolledCancellable: AnyCancellable? + private var refreshEnrollmentsCancellable: AnyCancellable? public init(interactor: DashboardInteractorProtocol, connectivity: ConnectivityProtocol, @@ -46,6 +47,14 @@ public class ListDashboardViewModel: ObservableObject { await self.getMyCourses(page: 1, refresh: true) } } + refreshEnrollmentsCancellable = NotificationCenter.default + .publisher(for: .refreshEnrollments) + .sink { [weak self] _ in + guard let self = self else { return } + Task { + await self.getMyCourses(page: 1, refresh: true) + } + } } @MainActor diff --git a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift index 7b3a51e37..60ffe9c0b 100644 --- a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift +++ b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift @@ -48,6 +48,7 @@ public class PrimaryCourseDashboardViewModel: ObservableObject { let enrollmentPublisher = NotificationCenter.default.publisher(for: .onCourseEnrolled) let completionPublisher = NotificationCenter.default.publisher(for: .onblockCompletionRequested) + let refreshEnrollmentsPublisher = NotificationCenter.default.publisher(for: .refreshEnrollments) enrollmentPublisher .sink { [weak self] _ in @@ -64,6 +65,15 @@ public class PrimaryCourseDashboardViewModel: ObservableObject { updateEnrollmentsIfNeeded() } .store(in: &cancellables) + + refreshEnrollmentsPublisher + .sink { [weak self] _ in + guard let self = self else { return } + Task { + await self.getEnrollments() + } + } + .store(in: &cancellables) } private func updateEnrollmentsIfNeeded() { diff --git a/Discussion/Discussion/Data/Model/Data_CommentsResponse.swift b/Discussion/Discussion/Data/Model/Data_CommentsResponse.swift index 0665d1cac..995917921 100644 --- a/Discussion/Discussion/Data/Model/Data_CommentsResponse.swift +++ b/Discussion/Discussion/Data/Model/Data_CommentsResponse.swift @@ -48,6 +48,7 @@ public extension DataLayer { public let childCount: Int public let children: [String] public let users: Users? + public let profileImage: ProfileImage? enum CodingKeys: String, CodingKey { case id = "id" @@ -71,6 +72,7 @@ public extension DataLayer { case childCount = "child_count" case children = "children" case users + case profileImage = "profile_image" } public init( @@ -94,7 +96,8 @@ public extension DataLayer { endorsedAt: String?, childCount: Int, children: [String], - users: Users? + users: Users?, + profileImage: ProfileImage? ) { self.id = id self.author = author @@ -117,6 +120,7 @@ public extension DataLayer { self.childCount = childCount self.children = children self.users = users + self.profileImage = profileImage } } } @@ -125,7 +129,7 @@ public extension DataLayer.Comments { var domain: UserComment { UserComment( authorName: author ?? DiscussionLocalization.anonymous, - authorAvatar: users?.userName?.profile?.image?.imageURLLarge ?? "", + authorAvatar: users?.userName?.profile?.image?.imageURLLarge ?? profileImage?.imageURLFull ?? "", postDate: Date(iso8601: createdAt), postTitle: "", postBody: rawBody, diff --git a/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift b/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift index 4409f22dc..1d433aa9b 100644 --- a/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift +++ b/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift @@ -186,7 +186,10 @@ enum DiscussionEndpoint: EndPointType { } return .requestParameters(parameters: parameters, encoding: URLEncoding.queryString) case .getThread: - return .requestParameters(parameters: [:], encoding: URLEncoding.queryString) + return .requestParameters( + parameters: ["requested_fields": "profile_image"], + encoding: URLEncoding.queryString + ) case .getTopics: return .requestParameters(encoding: URLEncoding.queryString) case let .getTopic(_, topicID): diff --git a/Discussion/Discussion/Data/Network/DiscussionRepository.swift b/Discussion/Discussion/Data/Network/DiscussionRepository.swift index 9ece98508..661ae603f 100644 --- a/Discussion/Discussion/Data/Network/DiscussionRepository.swift +++ b/Discussion/Discussion/Data/Network/DiscussionRepository.swift @@ -32,9 +32,6 @@ public protocol DiscussionRepositoryProtocol { func followThread(following: Bool, threadID: String) async throws func createNewThread(newThread: DiscussionNewThread) async throws func readBody(threadID: String) async throws - func renameThreadUser(data: Data) async throws -> DataLayer.ThreadListsResponse - func renameUsers(data: Data) async throws -> DataLayer.CommentsResponse - func renameUsersInJSON(stringJSON: String) -> String } public class DiscussionRepository: DiscussionRepositoryProtocol { @@ -66,21 +63,20 @@ public class DiscussionRepository: DiscussionRepositoryProtocol { let threads = try await api.requestData(DiscussionEndpoint .getThreads(courseID: courseID, type: type, sort: sort, filter: filter, page: page)) - return try await renameThreadUser(data: threads).domain + return try await renameThreadListUser(data: threads).domain } public func getThread(threadID: String) async throws -> UserThread { let thread = try await api.requestData(DiscussionEndpoint .getThread(threadID: threadID)) - .mapResponse(DataLayer.ThreadList.self) - return thread.userThread + return try await renameThreadUser(data: thread).userThread } public func searchThreads(courseID: String, searchText: String, pageNumber: Int) async throws -> ThreadLists { let posts = try await api.requestData(DiscussionEndpoint.searchThreads(courseID: courseID, searchText: searchText, pageNumber: pageNumber)) - return try await renameThreadUser(data: posts).domain + return try await renameThreadListUser(data: posts).domain } public func getTopics(courseID: String) async throws -> Topics { @@ -158,7 +154,7 @@ public class DiscussionRepository: DiscussionRepositoryProtocol { _ = try await api.request(DiscussionEndpoint.readBody(threadID: threadID)) } - public func renameThreadUser(data: Data) async throws -> DataLayer.ThreadListsResponse { + private func renameThreadListUser(data: Data) async throws -> DataLayer.ThreadListsResponse { var modifiedJSON = "" let parsed = try data.mapResponse(DataLayer.ThreadListsResponse.self) @@ -176,7 +172,25 @@ public class DiscussionRepository: DiscussionRepositoryProtocol { } } - public func renameUsers(data: Data) async throws -> DataLayer.CommentsResponse { + private func renameThreadUser(data: Data) async throws -> DataLayer.ThreadList { + var modifiedJSON = "" + let parsed = try data.mapResponse(DataLayer.ThreadList.self) + + if let stringJSON = String(data: data, encoding: .utf8) { + modifiedJSON = renameUsersInJSON(stringJSON: stringJSON) + if let modifiedParsed = try modifiedJSON.data(using: .utf8)?.mapResponse( + DataLayer.ThreadList.self + ) { + return modifiedParsed + } else { + return parsed + } + } else { + return parsed + } + } + + private func renameUsers(data: Data) async throws -> DataLayer.CommentsResponse { var modifiedJSON = "" let parsed = try data.mapResponse(DataLayer.CommentsResponse.self) @@ -192,7 +206,7 @@ public class DiscussionRepository: DiscussionRepositoryProtocol { } } - public func renameUsersInJSON(stringJSON: String) -> String { + private func renameUsersInJSON(stringJSON: String) -> String { var modifiedJSON = stringJSON let userNames = stringJSON.find(from: "\"users\":{\"", to: "\":{\"profile\":") if userNames.count >= 1 { @@ -478,42 +492,6 @@ public class DiscussionRepositoryMock: DiscussionRepositoryProtocol { public func readBody(threadID: String) async throws { } - - public func renameThreadUser(data: Data) async throws -> DataLayer.ThreadListsResponse { - DataLayer.ThreadListsResponse(threads: [], - textSearchRewrite: "", - pagination: DataLayer.Pagination(next: "", previous: "", count: 0, numPages: 0) ) - } - - public func renameUsers(data: Data) async throws -> DataLayer.CommentsResponse { - DataLayer.CommentsResponse( - comments: [ - DataLayer.Comments(id: "", author: "Bill", - authorLabel: nil, - createdAt: "25.11.2043", - updatedAt: "25.11.2043", - rawBody: "Raw Body", - renderedBody: "Rendered body", - abuseFlagged: false, - voted: true, - voteCount: 2, - editableFields: [], - canDelete: true, - threadID: "", - parentID: nil, - endorsed: false, - endorsedBy: nil, - endorsedByLabel: nil, - endorsedAt: nil, - childCount: 0, - children: [], - users: nil) - ], pagination: DataLayer.Pagination(next: nil, previous: nil, count: 0, numPages: 0)) - } - - public func renameUsersInJSON(stringJSON: String) -> String { - return stringJSON - } } #endif // swiftlint:enable all diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 8341e2233..5ab7602d4 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -40,6 +40,9 @@ 0770DE4C28D0A462006D8A5D /* Authorization.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0770DE4A28D0A462006D8A5D /* Authorization.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 0770DE5028D0A707006D8A5D /* NetworkAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE4F28D0A707006D8A5D /* NetworkAssembly.swift */; }; 0770DE6428D0BCC7006D8A5D /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0770DE6628D0BCC7006D8A5D /* Localizable.strings */; }; + 0780ABE32BFBA2E40093A4A6 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 0780ABE22BFBA2E40093A4A6 /* FirebaseAnalytics */; }; + 0780ABE52BFBA2E40093A4A6 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 0780ABE42BFBA2E40093A4A6 /* FirebaseMessaging */; }; + 0780ABE82BFCA1530093A4A6 /* NotificationsEndpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0780ABE72BFCA1530093A4A6 /* NotificationsEndpoints.swift */; }; 07A7D78F28F5C9060000BE81 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 07A7D78E28F5C9060000BE81 /* Core.framework */; }; 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 */; }; @@ -124,6 +127,7 @@ 0770DE4A28D0A462006D8A5D /* Authorization.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Authorization.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0770DE4F28D0A707006D8A5D /* NetworkAssembly.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkAssembly.swift; sourceTree = ""; }; 0770DE6528D0BCC7006D8A5D /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 0780ABE72BFCA1530093A4A6 /* NotificationsEndpoints.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsEndpoints.swift; sourceTree = ""; }; 07A7D78E28F5C9060000BE81 /* Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 07D5DA3128D075AA00752FD9 /* OpenEdX.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = OpenEdX.app; sourceTree = BUILT_PRODUCTS_DIR; }; 07D5DA3428D075AA00752FD9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -165,7 +169,9 @@ A5462D9F2B865713003B96A5 /* Segment in Frameworks */, 072787B128D34D83002E9142 /* Discovery.framework in Frameworks */, 0218196428F734FA00202564 /* Discussion.framework in Frameworks */, + 0780ABE52BFBA2E40093A4A6 /* FirebaseMessaging in Frameworks */, 0219C67728F4347600D64452 /* Course.framework in Frameworks */, + 0780ABE32BFBA2E40093A4A6 /* FirebaseAnalytics in Frameworks */, 027DB33028D8A063002B6862 /* Dashboard.framework in Frameworks */, A5BD3E302B83B0F7006A8983 /* SegmentFirebase in Frameworks */, A51CDBED2B6D2BEE009B6D4E /* SegmentBraze in Frameworks */, @@ -180,6 +186,7 @@ 0293A2012A6FC9E30090A336 /* Data */ = { isa = PBXGroup; children = ( + 0780ABE62BFC9C9D0093A4A6 /* Network */, 025AD4AB2A6FB95C00AB8FA7 /* DatabaseManager.swift */, 0293A2022A6FCA590090A336 /* CorePersistence.swift */, 0293A2042A6FCD430090A336 /* CoursePersistence.swift */, @@ -209,6 +216,14 @@ path = DI; sourceTree = ""; }; + 0780ABE62BFC9C9D0093A4A6 /* Network */ = { + isa = PBXGroup; + children = ( + 0780ABE72BFCA1530093A4A6 /* NotificationsEndpoints.swift */, + ); + path = Network; + sourceTree = ""; + }; 07D5DA2828D075AA00752FD9 = { isa = PBXGroup; children = ( @@ -410,6 +425,8 @@ A51CDBEE2B6D2BEE009B6D4E /* SegmentBrazeUI */, A5BD3E2F2B83B0F7006A8983 /* SegmentFirebase */, A5462D9E2B865713003B96A5 /* Segment */, + 0780ABE22BFBA2E40093A4A6 /* FirebaseAnalytics */, + 0780ABE42BFBA2E40093A4A6 /* FirebaseMessaging */, ); productName = OpenEdX; productReference = 07D5DA3128D075AA00752FD9 /* OpenEdX.app */; @@ -445,6 +462,7 @@ A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */, A5BD3E2E2B83B0F7006A8983 /* XCRemoteSwiftPackageReference "analytics-swift-firebase" */, A5462D9D2B865713003B96A5 /* XCRemoteSwiftPackageReference "analytics-swift" */, + 0780ABE12BFBA2E40093A4A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, ); productRefGroup = 07D5DA3228D075AA00752FD9 /* Products */; projectDirPath = ""; @@ -560,6 +578,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 0780ABE82BFCA1530093A4A6 /* NotificationsEndpoints.swift in Sources */, A500668B2B613ED10024680B /* PushNotificationsManager.swift in Sources */, 0293A2052A6FCD430090A336 /* CoursePersistence.swift in Sources */, 020CA5D92AA0A25300970AAF /* AppStorage.swift in Sources */, @@ -1211,6 +1230,14 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ + 0780ABE12BFBA2E40093A4A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 10.26.0; + }; + }; A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/braze-inc/braze-segment-swift"; @@ -1246,6 +1273,16 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ + 0780ABE22BFBA2E40093A4A6 /* FirebaseAnalytics */ = { + isa = XCSwiftPackageProductDependency; + package = 0780ABE12BFBA2E40093A4A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseAnalytics; + }; + 0780ABE42BFBA2E40093A4A6 /* FirebaseMessaging */ = { + isa = XCSwiftPackageProductDependency; + package = 0780ABE12BFBA2E40093A4A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseMessaging; + }; A51CDBEC2B6D2BEE009B6D4E /* SegmentBraze */ = { isa = XCSwiftPackageProductDependency; package = A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */; diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index 59138b48c..36cea0ba7 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -12,6 +12,9 @@ import Profile import GoogleSignIn import FacebookCore import MSAL +import UserNotifications +import FirebaseCore +import FirebaseMessaging import Theme @UIApplicationMain @@ -32,6 +35,7 @@ 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 @@ -42,6 +46,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ) } configureDeepLinkServices(launchOptions: launchOptions) + + let pushManager = Container.shared.resolve(PushNotificationsManager.self) + + if config.firebase.enabled { + FirebaseApp.configure() + if config.firebase.cloudMessagingEnabled { + Messaging.messaging().delegate = pushManager + UNUserNotificationCenter.current().delegate = pushManager + } + } + + if pushManager?.hasProviders == true { + UIApplication.shared.registerForRemoteNotifications() + } } Theme.Fonts.registerFonts() @@ -49,17 +67,20 @@ class AppDelegate: UIResponder, UIApplicationDelegate { window?.rootViewController = RouteController() window?.makeKeyAndVisible() window?.tintColor = Theme.UIColors.accentColor - + NotificationCenter.default.addObserver( self, - selector: #selector(forceLogoutUser), - name: .onTokenRefreshFailed, + selector: #selector(didUserAuthorize), + name: .userAuthorized, object: nil ) - if let pushManager = Container.shared.resolve(PushNotificationsManager.self) { - pushManager.performRegistration() - } + NotificationCenter.default.addObserver( + self, + selector: #selector(didUserLogout), + name: .userLoggedOut, + object: nil + ) return true } @@ -120,21 +141,31 @@ class AppDelegate: UIResponder, UIApplicationDelegate { ) } - @objc private func forceLogoutUser() { + @objc private func didUserAuthorize() { + Container.shared.resolve(PushNotificationsManager.self)?.synchronizeToken() + } + + @objc func didUserLogout(_ notification: Notification) { guard Date().timeIntervalSince1970 - lastForceLogoutTime > 5 else { return } - let analyticsManager = Container.shared.resolve(AnalyticsManager.self) - analyticsManager?.userLogout(force: true) - - lastForceLogoutTime = Date().timeIntervalSince1970 - - Container.shared.resolve(CoreStorage.self)?.clear() - Task { - await Container.shared.resolve(DownloadManagerProtocol.self)?.deleteAllFiles() + if let userInfo = notification.userInfo, + userInfo[Notification.UserInfoKey.isForced] as? Bool == true { + let analyticsManager = Container.shared.resolve(AnalyticsManager.self) + analyticsManager?.userLogout(force: true) + + lastForceLogoutTime = Date().timeIntervalSince1970 + + Container.shared.resolve(CoreStorage.self)?.clear() + Task { + await Container.shared.resolve(DownloadManagerProtocol.self)?.deleteAllFiles() + } + Container.shared.resolve(CoreDataHandlerProtocol.self)?.clear() + window?.rootViewController = RouteController() } - Container.shared.resolve(CoreDataHandlerProtocol.self)?.clear() - window?.rootViewController = RouteController() + + UNUserNotificationCenter.current().removeAllDeliveredNotifications() + Container.shared.resolve(PushNotificationsManager.self)?.refreshToken() } // Push Notifications @@ -151,8 +182,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) { - guard let pushManager = Container.shared.resolve(PushNotificationsManager.self) - else { + guard let pushManager = Container.shared.resolve(PushNotificationsManager.self) else { completionHandler(.newData) return } diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 6b722f8c2..cabc57d33 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -174,6 +174,9 @@ class AppAssembly: Assembly { container.register(PushNotificationsManager.self) { r in PushNotificationsManager( + deepLinkManager: r.resolve(DeepLinkManager.self)!, + storage: r.resolve(CoreStorage.self)!, + api: r.resolve(API.self)!, config: r.resolve(ConfigProtocol.self)! ) }.inObjectScope(.container) @@ -196,10 +199,8 @@ class AppAssembly: Assembly { ) }.inObjectScope(.container) - container.register(FirebaseAnalyticsService.self) { r in - FirebaseAnalyticsService( - config: r.resolve(ConfigProtocol.self)! - ) + container.register(FirebaseAnalyticsService.self) { _ in + FirebaseAnalyticsService() }.inObjectScope(.container) container.register(PipManagerProtocol.self) { r in diff --git a/OpenEdX/Data/AppStorage.swift b/OpenEdX/Data/AppStorage.swift index e21a94513..0d75f2851 100644 --- a/OpenEdX/Data/AppStorage.swift +++ b/OpenEdX/Data/AppStorage.swift @@ -34,45 +34,29 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto } } } - - public var reviewLastShownVersion: String? { + + public var refreshToken: String? { get { - return userDefaults.string(forKey: KEY_REVIEW_LAST_SHOWN_VERSION) + return keychain.get(KEY_REFRESH_TOKEN) } set(newValue) { if let newValue { - userDefaults.set(newValue, forKey: KEY_REVIEW_LAST_SHOWN_VERSION) + keychain.set(newValue, forKey: KEY_REFRESH_TOKEN) } else { - userDefaults.removeObject(forKey: KEY_REVIEW_LAST_SHOWN_VERSION) + keychain.delete(KEY_REFRESH_TOKEN) } } } - public var lastReviewDate: Date? { - get { - guard let dateString = userDefaults.string(forKey: KEY_REVIEW_LAST_REVIEW_DATE) else { - return nil - } - return Date(iso8601: dateString) - } - set(newValue) { - if let newValue { - userDefaults.set(newValue.dateToString(style: .iso8601), forKey: KEY_REVIEW_LAST_REVIEW_DATE) - } else { - userDefaults.removeObject(forKey: KEY_REVIEW_LAST_REVIEW_DATE) - } - } - } - - public var refreshToken: String? { + public var pushToken: String? { get { - return keychain.get(KEY_REFRESH_TOKEN) + return keychain.get(KEY_PUSH_TOKEN) } set(newValue) { if let newValue { - keychain.set(newValue, forKey: KEY_REFRESH_TOKEN) + keychain.set(newValue, forKey: KEY_PUSH_TOKEN) } else { - keychain.delete(KEY_REFRESH_TOKEN) + keychain.delete(KEY_PUSH_TOKEN) } } } @@ -116,6 +100,35 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto } } + public var reviewLastShownVersion: String? { + get { + return userDefaults.string(forKey: KEY_REVIEW_LAST_SHOWN_VERSION) + } + set(newValue) { + if let newValue { + userDefaults.set(newValue, forKey: KEY_REVIEW_LAST_SHOWN_VERSION) + } else { + userDefaults.removeObject(forKey: KEY_REVIEW_LAST_SHOWN_VERSION) + } + } + } + + public var lastReviewDate: Date? { + get { + guard let dateString = userDefaults.string(forKey: KEY_REVIEW_LAST_REVIEW_DATE) else { + return nil + } + return Date(iso8601: dateString) + } + set(newValue) { + if let newValue { + userDefaults.set(newValue.dateToString(style: .iso8601), forKey: KEY_REVIEW_LAST_REVIEW_DATE) + } else { + userDefaults.removeObject(forKey: KEY_REVIEW_LAST_REVIEW_DATE) + } + } + } + public var whatsNewVersion: String? { get { return userDefaults.string(forKey: KEY_WHATSNEW_VERSION) @@ -222,10 +235,12 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto refreshToken = nil cookiesDate = nil user = nil + userProfile = nil } private let KEY_ACCESS_TOKEN = "accessToken" private let KEY_REFRESH_TOKEN = "refreshToken" + private let KEY_PUSH_TOKEN = "pushToken" private let KEY_COOKIES_DATE = "cookiesDate" private let KEY_USER_PROFILE = "userProfile" private let KEY_USER = "refreshToken" diff --git a/OpenEdX/Data/Network/NotificationsEndpoints.swift b/OpenEdX/Data/Network/NotificationsEndpoints.swift new file mode 100644 index 000000000..8b5639f6c --- /dev/null +++ b/OpenEdX/Data/Network/NotificationsEndpoints.swift @@ -0,0 +1,44 @@ +// +// NotificationsEndpoints.swift +// OpenEdX +// +// Created by Volodymyr Chekyrta on 21.05.24. +// + +import Foundation +import Core +import Alamofire + +enum NotificationsEndpoints: EndPointType { + + case syncFirebaseToken(token: String) + + var path: String { + switch self { + case .syncFirebaseToken: + return "/api/mobile/v4/notifications/create-token/" + } + } + + var httpMethod: HTTPMethod { + switch self { + case .syncFirebaseToken: + return .post + } + } + + var headers: HTTPHeaders? { + nil + } + + var task: HTTPTask { + switch self { + case let .syncFirebaseToken(token): + let params: [String: Encodable] = [ + "registration_id": token, + "active": true + ] + return .requestParameters(parameters: params, encoding: URLEncoding.httpBody) + } + } +} diff --git a/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift b/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift index be43a71c6..20262a0ef 100644 --- a/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift +++ b/OpenEdX/Managers/DeepLinkManager/DeepLinkManager.swift @@ -38,11 +38,11 @@ public class DeepLinkManager { private let discussionInteractor: DiscussionInteractorProtocol private let courseInteractor: CourseInteractorProtocol private let profileInteractor: ProfileInteractorProtocol - + var userloggedIn: Bool { - return !(storage.user?.username?.isEmpty ?? true) - } - + return !(storage.user?.username?.isEmpty ?? true) + } + public init( config: ConfigProtocol, router: DeepLinkRouter, @@ -59,7 +59,7 @@ public class DeepLinkManager { self.discussionInteractor = discussionInteractor self.courseInteractor = courseInteractor self.profileInteractor = profileInteractor - + services = servicesFor(config: config) } @@ -102,9 +102,9 @@ public class DeepLinkManager { guard link.type != .none else { return } - + let isAppActive = UIApplication.shared.applicationState == .active - + Task { if isAppActive { await showNotificationAlert(link) @@ -124,11 +124,11 @@ public class DeepLinkManager { } } } - + @MainActor private func showNotificationAlert(_ link: PushLink) { router.dismissPresentedViewController() - + router.presentAlert( alertTitle: link.title ?? "", alertMessage: link.body ?? "", @@ -148,17 +148,19 @@ public class DeepLinkManager { type: .deepLink ) } - + private func isDiscovery(type: DeepLinkType) -> Bool { type == .discovery || type == .discoveryCourseDetail || type == .discoveryProgramDetail } - + private func isDiscussionThreads(type: DeepLinkType) -> Bool { type == .discussionPost || type == .discussionTopic || - type == .discussionComment + type == .discussionComment || + type == .forumResponse || + type == .forumComment } private func isHandout(type: DeepLinkType) -> Bool { @@ -195,9 +197,14 @@ public class DeepLinkManager { .courseAnnouncement, .discussionTopic, .discussionPost, + .forumResponse, + .forumComment, .discussionComment, .courseComponent: await showCourseScreen(with: type, link: link) + case .enroll, .addBetaTester: + await showCourseScreen(with: type, link: link) + NotificationCenter.default.post(name: .refreshEnrollments, object: nil) case .program, .programDetail: guard config.program.enabled else { return } if let pathID = link.pathID, !pathID.isEmpty { @@ -209,6 +216,9 @@ public class DeepLinkManager { router.showTabScreen(tab: .profile) case .userProfile: await showEditProfile() + case .unenroll, .removeBetaTester: + router.showTabScreen(tab: .dashboard) + NotificationCenter.default.post(name: .refreshEnrollments, object: nil) default: break } @@ -248,9 +258,8 @@ public class DeepLinkManager { link: link, courseDetails: courseDetails ) { [weak self] in - guard let self else { - return - } + guard let self else { return } + guard courseDetails.isEnrolled else { return } if self.isHandout(type: type) { self.router.showProgress() @@ -344,7 +353,6 @@ public class DeepLinkManager { isBlackedOut: isBlackedOut ) case .discussionPost: - if let topicID = link.topicID, !topicID.isEmpty, let topics = try? await discussionInteractor.getTopic( @@ -367,8 +375,7 @@ public class DeepLinkManager { isBlackedOut: isBlackedOut ) } - - case .discussionComment: + case .discussionComment, .forumResponse: if let topicID = link.topicID, !topicID.isEmpty, let topics = try? await discussionInteractor.getTopic( @@ -404,6 +411,42 @@ public class DeepLinkManager { isBlackedOut: isBlackedOut ) } + case .forumComment: + if let topicID = link.topicID, + !topicID.isEmpty, + let topics = try? await discussionInteractor.getTopic( + courseID: courseDetails.courseID, + topicID: topicID + ) { + router.showThreads( + topicID: topicID, + courseDetails: courseDetails, + topics: topics, + isBlackedOut: isBlackedOut + ) + } + + if let threadID = link.threadID, + !threadID.isEmpty, + let userThread = try? await discussionInteractor.getThread(threadID: threadID) { + router.showThread( + userThread: userThread, + isBlackedOut: isBlackedOut + ) + } + + if let parentID = link.parentID, + !parentID.isEmpty, + let comment = try? await self.discussionInteractor.getResponse(responseID: parentID), + let commentParentID = comment.parentID, + !commentParentID.isEmpty, + let parentComment = try? await self.discussionInteractor.getResponse(responseID: commentParentID) { + router.showComment( + comment: comment, + parentComment: parentComment.post, + isBlackedOut: isBlackedOut + ) + } default: break } diff --git a/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift b/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift index 0ae41e30a..15f026235 100644 --- a/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift +++ b/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift @@ -112,7 +112,7 @@ extension Router: DeepLinkRouter { courseEnd: courseDetails.courseEnd, enrollmentStart: courseDetails.enrollmentStart, enrollmentEnd: courseDetails.enrollmentEnd, - title: courseDetails.courseTitle, + title: courseDetails.courseTitle, showDates: false, lastVisitedBlockID: nil ) @@ -131,7 +131,9 @@ extension Router: DeepLinkRouter { .courseHandout, .courseAnnouncement, .courseDashboard, - .courseComponent: + .courseComponent, + .enroll, + .addBetaTester: popToCourseContainerView(animated: false) default: break diff --git a/OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift b/OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift index 1da67d716..90ca14792 100644 --- a/OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift +++ b/OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift @@ -26,6 +26,12 @@ enum DeepLinkType: String { case programDetail = "program_detail" case userProfile = "user_profile" case profile = "profile" + case forumResponse = "forum_response" + case forumComment = "forum_comment" + case enroll = "enroll" + case unenroll = "unenroll" + case addBetaTester = "add_beta_tester" + case removeBetaTester = "remove_beta_tester" case none } @@ -33,30 +39,38 @@ private enum DeepLinkKeys: String, RawStringExtractable { case courseID = "course_id" case pathID = "path_id" case screenName = "screen_name" + case notificationType = "notification_type" case topicID = "topic_id" case threadID = "thread_id" case commentID = "comment_id" + case parentID = "parent_id" case componentID = "component_id" } public class DeepLink { let courseID: String? let screenName: String? + let notificationType: String? let pathID: String? let topicID: String? let threadID: String? let commentID: String? + let parentID: String? let componentID: String? var type: DeepLinkType init(dictionary: [AnyHashable: Any]) { courseID = dictionary[DeepLinkKeys.courseID.rawValue] as? String screenName = dictionary[DeepLinkKeys.screenName.rawValue] as? String + notificationType = dictionary[DeepLinkKeys.notificationType.rawValue] as? String pathID = dictionary[DeepLinkKeys.pathID.rawValue] as? String topicID = dictionary[DeepLinkKeys.topicID.rawValue] as? String threadID = dictionary[DeepLinkKeys.threadID.rawValue] as? String commentID = dictionary[DeepLinkKeys.commentID.rawValue] as? String componentID = dictionary[DeepLinkKeys.componentID.rawValue] as? String - type = DeepLinkType(rawValue: screenName ?? DeepLinkType.none.rawValue) ?? .none + parentID = dictionary[DeepLinkKeys.parentID.rawValue] as? String + type = DeepLinkType( + rawValue: screenName ?? notificationType ?? DeepLinkType.none.rawValue + ) ?? .none } } diff --git a/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift b/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift index 12bd225e8..fe49bc007 100644 --- a/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift +++ b/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift @@ -6,19 +6,13 @@ // import Foundation -import Firebase import Core +import FirebaseAnalytics private let MaxParameterValueCharacters = 100 private let MaxNameValueCharacters = 40 class FirebaseAnalyticsService: AnalyticsService { - // Init manager - public init(config: ConfigProtocol) { - guard config.firebase.enabled && config.firebase.isAnalyticsSourceFirebase else { return } - - FirebaseApp.configure() - } func identify(id: String, username: String?, email: String?) { Analytics.setUserID(id) diff --git a/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift b/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift index 5d049e02e..b117843fc 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift @@ -8,9 +8,23 @@ import Foundation class BrazeListener: PushNotificationsListener { + + private let deepLinkManager: DeepLinkManager + + init(deepLinkManager: DeepLinkManager) { + self.deepLinkManager = deepLinkManager + } + func shouldListenNotification(userinfo: [AnyHashable: Any]) -> Bool { //A push notification sent from the braze has a key ab in it like ab = {c = "c_value";}; let data = userinfo["ab"] as? [String: Any] return userinfo.count > 0 && data != nil } + + func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) { + guard let dictionary = userInfo as? [String: AnyHashable], + shouldListenNotification(userinfo: userInfo) else { return } + let link = PushLink(dictionary: dictionary) + deepLinkManager.processLinkFromNotification(link) + } } diff --git a/OpenEdX/Managers/PushNotificationsManager/Listeners/FCMListener.swift b/OpenEdX/Managers/PushNotificationsManager/Listeners/FCMListener.swift index b0ed7f5f8..5d951e1a8 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Listeners/FCMListener.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Listeners/FCMListener.swift @@ -6,8 +6,29 @@ // import Foundation +import FirebaseMessaging class FCMListener: PushNotificationsListener { + + private let deepLinkManager: DeepLinkManager + + init(deepLinkManager: DeepLinkManager) { + self.deepLinkManager = deepLinkManager + } + // check if userinfo contains data for this Listener - func shouldListenNotification(userinfo: [AnyHashable: Any]) -> Bool { false } + func shouldListenNotification(userinfo: [AnyHashable: Any]) -> Bool { + let data = userinfo["gcm.message_id"] + return userinfo.count > 0 && data != nil + } + + func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) { + guard let dictionary = userInfo as? [String: AnyHashable], + shouldListenNotification(userinfo: userInfo) else { return } + // With swizzling disabled you must let Messaging know about the message, for Analytics + Messaging.messaging().appDidReceiveMessage(userInfo) + + let link = PushLink(dictionary: dictionary) + deepLinkManager.processLinkFromNotification(link) + } } diff --git a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift index 9e45059e3..43ca0c780 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift @@ -25,4 +25,10 @@ class BrazeProvider: PushNotificationsProvider { func didFailToRegisterForRemoteNotificationsWithError(error: Error) { } + + func synchronizeToken() { + } + + func refreshToken() { + } } diff --git a/OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift b/OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift index 4e66a2a30..6a418ce5f 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift @@ -6,13 +6,58 @@ // import Foundation +import Core +import FirebaseCore +import FirebaseMessaging -class FCMProvider: PushNotificationsProvider { +class FCMProvider: NSObject, PushNotificationsProvider, MessagingDelegate { + + private var storage: CoreStorage + private let api: API + + init(storage: CoreStorage, api: API) { + self.storage = storage + self.api = api + } + func didRegisterWithDeviceToken(deviceToken: Data) { - + Messaging.messaging().apnsToken = deviceToken } func didFailToRegisterForRemoteNotificationsWithError(error: Error) { + } + + func synchronizeToken() { + guard let fcmToken = storage.pushToken, storage.user != nil else { return } + sendFCMToken(fcmToken) + } + + func refreshToken() { + Messaging.messaging().deleteToken { error in + if let error = error { + debugLog("Error deleting FCM token: \(error.localizedDescription)") + } else { + Messaging.messaging().token { token, error in + if let error = error { + debugLog("Error fetching FCM token: \(error.localizedDescription)") + } else if let token = token { + debugLog("FCM token fetched: \(token)") + } + } + } + } + } + + func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { + storage.pushToken = fcmToken + guard let fcmToken, storage.user != nil else { return } + sendFCMToken(fcmToken) + } + + private func sendFCMToken(_ token: String) { + Task { + try? await api.request(NotificationsEndpoints.syncFirebaseToken(token: token)) + } } } diff --git a/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift b/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift index be832bcbb..1f6ab02b4 100644 --- a/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift +++ b/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift @@ -8,11 +8,15 @@ import Foundation import Core import UIKit -import Swinject +import UserNotifications +import FirebaseCore +import FirebaseMessaging public protocol PushNotificationsProvider { func didRegisterWithDeviceToken(deviceToken: Data) func didFailToRegisterForRemoteNotificationsWithError(error: Error) + func synchronizeToken() + func refreshToken() } protocol PushNotificationsListener { @@ -20,45 +24,46 @@ protocol PushNotificationsListener { func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) } -extension PushNotificationsListener { - func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) { - guard let dictionary = userInfo as? [String: AnyHashable], - shouldListenNotification(userinfo: userInfo), - let deepLinkManager = Container.shared.resolve(DeepLinkManager.self) - else { return } - let link = PushLink(dictionary: dictionary) - deepLinkManager.processLinkFromNotification(link) - } -} - -class PushNotificationsManager { +class PushNotificationsManager: NSObject { + + private let deepLinkManager: DeepLinkManager + private let storage: CoreStorage + private let api: API private var providers: [PushNotificationsProvider] = [] private var listeners: [PushNotificationsListener] = [] + public var hasProviders: Bool { + providers.count > 0 + } + // Init manager - public init(config: ConfigProtocol) { + public init(deepLinkManager: DeepLinkManager, storage: CoreStorage, api: API, config: ConfigProtocol) { + self.deepLinkManager = deepLinkManager + self.storage = storage + self.api = api + super.init() 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()) } + if config.firebase.cloudMessagingEnabled { + pushProviders.append(FCMProvider(storage: storage, api: api)) + } 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()) + pushListeners.append(BrazeListener(deepLinkManager: deepLinkManager)) + } + if config.firebase.cloudMessagingEnabled { + pushListeners.append(FCMListener(deepLinkManager: deepLinkManager)) } return pushListeners } @@ -67,9 +72,7 @@ class PushNotificationsManager { public func performRegistration() { UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound, .badge]) { (granted, error) in if granted { - DispatchQueue.main.async { - UIApplication.shared.registerForRemoteNotifications() - } + debugLog("Permission for push notifications granted.") } else if let error = error { debugLog("Push notifications permission error: \(error.localizedDescription)") } else { @@ -94,4 +97,49 @@ class PushNotificationsManager { listener.didReceiveRemoteNotification(userInfo: userInfo) } } + + func synchronizeToken() { + for provider in providers { + provider.synchronizeToken() + } + } + + func refreshToken() { + for provider in providers { + provider.refreshToken() + } + } +} + +// MARK: - MessagingDelegate +extension PushNotificationsManager: MessagingDelegate { + func messaging(_ messaging: Messaging, didReceiveRegistrationToken fcmToken: String?) { + for provider in providers where provider is MessagingDelegate { + (provider as? MessagingDelegate)?.messaging?(messaging, didReceiveRegistrationToken: fcmToken) + } + } +} + +// MARK: - UNUserNotificationCenterDelegate +extension PushNotificationsManager: UNUserNotificationCenterDelegate { + @MainActor + func userNotificationCenter( + _ center: UNUserNotificationCenter, + willPresent notification: UNNotification + ) async -> UNNotificationPresentationOptions { + if UIApplication.shared.applicationState == .active { + didReceiveRemoteNotification(userInfo: notification.request.content.userInfo) + } + + return [[.list, .banner, .sound]] + } + + @MainActor + func userNotificationCenter( + _ center: UNUserNotificationCenter, + didReceive response: UNNotificationResponse + ) async { + let userInfo = response.notification.request.content.userInfo + didReceiveRemoteNotification(userInfo: userInfo) + } } diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 8dbbafa20..fce1cca5d 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -380,6 +380,10 @@ public class Router: AuthorizationRouter, lastVisitedBlockID: lastVisitedBlockID ) navigationController.pushViewController(controller, animated: true) + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + Container.shared.resolve(PushNotificationsManager.self)?.performRegistration() + } } public func getCourseScreensController( diff --git a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift index e31e09eea..ce6c4477b 100644 --- a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift +++ b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift @@ -139,6 +139,11 @@ public class SettingsViewModel: ObservableObject { try? await downloadManager.cancelAllDownloading() router.showStartupScreen() analytics.userLogout(force: false) + NotificationCenter.default.post( + name: .userLoggedOut, + object: nil, + userInfo: [Notification.UserInfoKey.isForced: false] + ) } func trackProfileVideoSettingsClicked() { From ea6e24e8ac0012397a502e9e3b99ea8967dc5453 Mon Sep 17 00:00:00 2001 From: Shafqat Muneer Date: Wed, 26 Jun 2024 16:47:20 +0500 Subject: [PATCH 22/55] feat: Program Screen Error Handling (#448) --- Core/Core.xcodeproj/project.pbxproj | 12 ++ .../FullScreenErrorView.swift | 97 ++++++++++++ Core/Core/View/Base/Webview/WebView.swift | 29 +++- Course/Course.xcodeproj/project.pbxproj | 14 +- .../CalendarSyncProgressView.swift | 0 .../DatesSuccessView}/DatesSuccessView.swift | 0 .../Presentation/Unit/CourseUnitView.swift | 32 +--- .../WebDiscovery/DiscoveryWebview.swift | 129 +++++++++------- .../DiscoveryWebviewViewModel.swift | 6 + .../WebPrograms/ProgramWebviewView.swift | 139 +++++++++++------- .../WebPrograms/ProgramWebviewViewModel.swift | 5 + 11 files changed, 321 insertions(+), 142 deletions(-) create mode 100644 Core/Core/View/Base/FullScreenErrorView/FullScreenErrorView.swift rename Course/Course/{Views => Presentation/Subviews/CalendarSyncProgressView}/CalendarSyncProgressView.swift (100%) rename Course/Course/{Views => Presentation/Subviews/DatesSuccessView}/DatesSuccessView.swift (100%) diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index ad4325f39..3e59ea038 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -144,6 +144,7 @@ 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 */; }; + 9784D47E2BF7762800AFEFFF /* FullScreenErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9784D47D2BF7762800AFEFFF /* FullScreenErrorView.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 */; }; @@ -339,6 +340,7 @@ 349B90CD6579F7B8D257E515 /* Pods_App_Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = ""; }; + 9784D47D2BF7762800AFEFFF /* FullScreenErrorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FullScreenErrorView.swift; 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 = ""; }; @@ -715,6 +717,7 @@ 0770DE7728D0C49E006D8A5D /* Base */ = { isa = PBXGroup; children = ( + 9784D47C2BF7761F00AFEFFF /* FullScreenErrorView */, 064987882B4D69FE0071642A /* Webview */, E0D586352B314CD3009B4BA7 /* LogistrationBottomView.swift */, 02A4833B29B8C57800D33F33 /* DownloadView.swift */, @@ -766,6 +769,14 @@ path = Analytics; sourceTree = ""; }; + 9784D47C2BF7761F00AFEFFF /* FullScreenErrorView */ = { + isa = PBXGroup; + children = ( + 9784D47D2BF7762800AFEFFF /* FullScreenErrorView.swift */, + ); + path = FullScreenErrorView; + sourceTree = ""; + }; BA30427C2B20B235009B64B7 /* SocialAuth */ = { isa = PBXGroup; children = ( @@ -1124,6 +1135,7 @@ 0260E58028FD792800BBBE18 /* WebUnitViewModel.swift in Sources */, 02A4833A29B8A9AB00D33F33 /* DownloadManager.swift in Sources */, 06078B702BA49C3100576798 /* Dictionary+JSON.swift in Sources */, + 9784D47E2BF7762800AFEFFF /* FullScreenErrorView.swift in Sources */, 027BD3AE2909475000392132 /* KeyboardScrollerOptions.swift in Sources */, BAFB99922B14E23D007D09F9 /* AppleSignInConfig.swift in Sources */, 141F1D302B7328D4009E81EB /* WebviewCookiesUpdateProtocol.swift in Sources */, diff --git a/Core/Core/View/Base/FullScreenErrorView/FullScreenErrorView.swift b/Core/Core/View/Base/FullScreenErrorView/FullScreenErrorView.swift new file mode 100644 index 000000000..dc5893621 --- /dev/null +++ b/Core/Core/View/Base/FullScreenErrorView/FullScreenErrorView.swift @@ -0,0 +1,97 @@ +// +// FullScreenErrorView.swift +// Course +// +// Created by Shafqat Muneer on 5/14/24. +// + +import SwiftUI +import Theme + +public struct FullScreenErrorView: View { + + public enum ErrorType { + case noInternet + case noInternetWithReload + case generic + } + + private let errorType: ErrorType + private var action: () -> Void = {} + + public init( + type: ErrorType + ) { + self.errorType = type + } + + public init( + type: ErrorType, + action: @escaping () -> Void + ) { + self.errorType = type + self.action = action + } + + public var body: some View { + GeometryReader { proxy in + VStack(spacing: 28) { + Spacer() + switch errorType { + case .noInternet, .noInternetWithReload: + CoreAssets.noWifi.swiftUIImage + .renderingMode(.template) + .foregroundStyle(Color.primary) + .scaledToFit() + + 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, 50) + case .generic: + CoreAssets.notAvaliable.swiftUIImage + .renderingMode(.template) + .foregroundStyle(Color.primary) + .scaledToFit() + + Text(CoreLocalization.View.Snackbar.tryAgainBtn) + .font(Theme.Fonts.titleLarge) + .foregroundColor(Theme.Colors.textPrimary) + + Text(CoreLocalization.Error.unknownError) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textPrimary) + .multilineTextAlignment(.center) + .padding(.horizontal, 50) + } + + if errorType != .noInternet { + UnitButtonView( + type: .reload, + action: { + self.action() + } + ) + } + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: proxy.size.height) + .background( + Theme.Colors.background + ) + } + } +} + +#if DEBUG +struct FullScreenErrorView_Previews: PreviewProvider { + static var previews: some View { + FullScreenErrorView(type: .noInternetWithReload) + } +} +#endif diff --git a/Core/Core/View/Base/Webview/WebView.swift b/Core/Core/View/Base/Webview/WebView.swift index cb36ea255..1b12167db 100644 --- a/Core/Core/View/Base/Webview/WebView.swift +++ b/Core/Core/View/Base/Webview/WebView.swift @@ -17,6 +17,8 @@ public protocol WebViewNavigationDelegate: AnyObject { shouldLoad request: URLRequest, navigationAction: WKNavigationAction ) async -> Bool + + func showWebViewError() } public struct WebView: UIViewRepresentable { @@ -39,17 +41,20 @@ public struct WebView: UIViewRepresentable { var webViewNavDelegate: WebViewNavigationDelegate? var refreshCookies: () async -> Void + var webViewType: String? public init( viewModel: ViewModel, isLoading: Binding, refreshCookies: @escaping () async -> Void, - navigationDelegate: WebViewNavigationDelegate? = nil + navigationDelegate: WebViewNavigationDelegate? = nil, + webViewType: String? = nil ) { self.viewModel = viewModel self._isLoading = isLoading self.refreshCookies = refreshCookies self.webViewNavDelegate = navigationDelegate + self.webViewType = webViewType } public class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate, WKScriptMessageHandler { @@ -70,6 +75,10 @@ public struct WebView: UIViewRepresentable { public func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { webView.isHidden = false + DispatchQueue.main.async { + self.parent.isLoading = false + self.parent.webViewNavDelegate?.showWebViewError() + } } public func webView( @@ -78,6 +87,10 @@ public struct WebView: UIViewRepresentable { withError error: Error ) { webView.isHidden = false + DispatchQueue.main.async { + self.parent.isLoading = false + self.parent.webViewNavDelegate?.showWebViewError() + } } public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { @@ -172,7 +185,7 @@ public struct WebView: UIViewRepresentable { private func addObservers() { cancellables.removeAll() - NotificationCenter.default.publisher(for: .webviewReloadNotification, object: nil) + NotificationCenter.default.publisher(for: Notification.Name(parent.webViewType ?? ""), object: nil) .sink { [weak self] _ in self?.reload() } @@ -188,8 +201,16 @@ public struct WebView: UIViewRepresentable { fileprivate var webview: WKWebView? @objc private func reload() { - parent.isLoading = true - webview?.reload() + DispatchQueue.main.async { + self.parent.isLoading = true + } + if webview?.url?.absoluteString.isEmpty ?? true, + let url = URL(string: parent.viewModel.url) { + let request = URLRequest(url: url) + webview?.load(request) + } else { + webview?.reload() + } } public func userContentController( diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index efc45a860..cc778b214 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -314,7 +314,6 @@ 02B6B3B528E1D10700232911 /* Domain */, 02EAE2CA28E1F0A700529644 /* Presentation */, 97EA4D822B84EFA900663F58 /* Managers */, - 97CA95212B875EA200A9EDEA /* Views */, 02B6B3B428E1C49400232911 /* Localizable.strings */, 02C355372C08DCD700501342 /* Localizable.stringsdict */, ); @@ -529,13 +528,20 @@ path = Mock; sourceTree = ""; }; - 97CA95212B875EA200A9EDEA /* Views */ = { + 9784D4752BF39EEF00AFEFFF /* CalendarSyncProgressView */ = { isa = PBXGroup; children = ( 97C99C352B9A08FE004EEDE2 /* CalendarSyncProgressView.swift */, + ); + path = CalendarSyncProgressView; + sourceTree = ""; + }; + 9784D4762BF39EFD00AFEFFF /* DatesSuccessView */ = { + isa = PBXGroup; + children = ( 97CA95242B875EE200A9EDEA /* DatesSuccessView.swift */, ); - path = Views; + path = DatesSuccessView; sourceTree = ""; }; 97EA4D822B84EFA900663F58 /* Managers */ = { @@ -586,6 +592,8 @@ BAD9CA482B2C88D500DE790A /* Subviews */ = { isa = PBXGroup; children = ( + 9784D4762BF39EFD00AFEFFF /* DatesSuccessView */, + 9784D4752BF39EEF00AFEFFF /* CalendarSyncProgressView */, 02D4FC2C2BBD7C7500C47748 /* MessageSectionView */, BAC0E0D92B32F0A2006B68A9 /* CourseVideoDownloadBarView */, BA58CF622B471047005B102E /* VideoDownloadQualityBarView */, diff --git a/Course/Course/Views/CalendarSyncProgressView.swift b/Course/Course/Presentation/Subviews/CalendarSyncProgressView/CalendarSyncProgressView.swift similarity index 100% rename from Course/Course/Views/CalendarSyncProgressView.swift rename to Course/Course/Presentation/Subviews/CalendarSyncProgressView/CalendarSyncProgressView.swift diff --git a/Course/Course/Views/DatesSuccessView.swift b/Course/Course/Presentation/Subviews/DatesSuccessView/DatesSuccessView.swift similarity index 100% rename from Course/Course/Views/DatesSuccessView.swift rename to Course/Course/Presentation/Subviews/DatesSuccessView/DatesSuccessView.swift diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index 44f1e4a1b..0248e2393 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -189,7 +189,7 @@ public struct CourseUnitView: View { Spacer(minLength: 150) } } else { - NoInternetView() + FullScreenErrorView(type: .noInternet) } } else { @@ -219,7 +219,7 @@ public struct CourseUnitView: View { Spacer(minLength: 150) } } else { - NoInternetView() + FullScreenErrorView(type: .noInternet) } } // MARK: Web @@ -233,7 +233,7 @@ public struct CourseUnitView: View { ) // not need to add frame limit there because we did that with injection } else { - NoInternetView() + FullScreenErrorView(type: .noInternet) } } else { EmptyView() @@ -247,7 +247,7 @@ public struct CourseUnitView: View { Spacer() .frame(minHeight: 100) } else { - NoInternetView() + FullScreenErrorView(type: .noInternet) } } else { EmptyView() @@ -275,7 +275,7 @@ public struct CourseUnitView: View { //No need iPad paddings there bacause they were added //to PostsView that placed inside DiscussionView } else { - NoInternetView() + FullScreenErrorView(type: .noInternet) } } else { EmptyView() @@ -586,25 +586,3 @@ struct CourseUnitView_Previews: PreviewProvider { } //swiftlint:enable all #endif - -struct NoInternetView: View { - - var body: some View { - VStack(spacing: 28) { - Spacer() - CoreAssets.noWifi.swiftUIImage - .renderingMode(.template) - .foregroundStyle(Color.primary) - .scaledToFit() - 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, 50) - Spacer() - }.frame(maxWidth: .infinity, maxHeight: .infinity) - } -} diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift index be01b0be4..b69bb3af9 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift @@ -14,13 +14,24 @@ public enum DiscoveryWebviewType: Equatable { case discovery case courseDetail(String) case programDetail(String) + + var rawValue: String { + switch self { + case .discovery: + return "discovery" + case .courseDetail(let value): + return "courseDetail(\(value))" + case .programDetail(let value): + return "programDetail(\(value))" + } + } } public struct DiscoveryWebview: View { @State private var searchQuery: String = "" @State private var isLoading: Bool = true - @ObservedObject private var viewModel: DiscoveryWebviewViewModel + @StateObject private var viewModel: DiscoveryWebviewViewModel private var router: DiscoveryRouter private var discoveryType: DiscoveryWebviewType public var pathID: String @@ -70,78 +81,86 @@ public struct DiscoveryWebview: View { discoveryType: DiscoveryWebviewType = .discovery, pathID: String = "" ) { - self.viewModel = viewModel + self._viewModel = .init(wrappedValue: viewModel) self.router = router self._searchQuery = State(initialValue: searchQuery ?? "") self.discoveryType = discoveryType self.pathID = pathID - - if let url = URL(string: URLString) { - viewModel.request = URLRequest(url: url) - } } public var body: some View { GeometryReader { proxy in - VStack(alignment: .center) { - WebView( - viewModel: .init( - url: URLString, - baseURL: "" - ), - isLoading: $isLoading, - refreshCookies: {}, - navigationDelegate: viewModel - ) - .accessibilityIdentifier("discovery_webview") - - if isLoading || viewModel.showProgress { - HStack(alignment: .center) { - ProgressBar( - size: 40, - lineWidth: 8 - ) - .padding(.vertical, proxy.size.height / 2) - .accessibilityIdentifier("progress_bar") + ZStack(alignment: .center) { + VStack(alignment: .center) { + WebView( + viewModel: .init( + url: URLString, + baseURL: "" + ), + isLoading: $isLoading, + refreshCookies: {}, + navigationDelegate: viewModel, + webViewType: discoveryType.rawValue + ) + .accessibilityIdentifier("discovery_webview") + + if isLoading || viewModel.showProgress { + HStack(alignment: .center) { + ProgressBar( + size: 40, + lineWidth: 8 + ) + .padding(.vertical, proxy.size.height / 2) + .accessibilityIdentifier("progress_bar") + } + .frame(width: proxy.size.width, height: proxy.size.height) } - .frame(width: proxy.size.width, height: proxy.size.height) - } - - // MARK: - Show Error - if viewModel.showError { - VStack { - SnackBarView(message: viewModel.errorMessage) + + // MARK: - Show Error + if viewModel.showError { + VStack { + SnackBarView(message: viewModel.errorMessage) + } + .padding(.bottom, 20) + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } + } } - .padding(.bottom, 20) - .transition(.move(edge: .bottom)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.errorMessage = nil + + if !viewModel.userloggedIn, !isLoading { + LogistrationBottomView { buttonAction in + switch buttonAction { + case .signIn: + viewModel.router.showLoginScreen(sourceScreen: sourceScreen) + case .register: + viewModel.router.showRegisterScreen(sourceScreen: sourceScreen) + } } } } - if !viewModel.userloggedIn, !isLoading { - LogistrationBottomView { buttonAction in - switch buttonAction { - case .signIn: - viewModel.router.showLoginScreen(sourceScreen: sourceScreen) - case .register: - viewModel.router.showRegisterScreen(sourceScreen: sourceScreen) + if viewModel.webViewError { + FullScreenErrorView( + type: viewModel.connectivity.isInternetAvaliable ? .generic : .noInternetWithReload + ) { + if viewModel.connectivity.isInternetAvaliable { + viewModel.webViewError = false + NotificationCenter.default.post( + name: Notification.Name(discoveryType.rawValue), + object: nil + ) } } } } - - // MARK: - Offline mode SnackBar - OfflineSnackBarView( - connectivity: viewModel.connectivity, - reloadAction: { - NotificationCenter.default.post( - name: .webviewReloadNotification, - object: nil - ) - }) + .onFirstAppear { + if let url = URL(string: URLString) { + viewModel.request = URLRequest(url: url) + } + } } .navigationBarHidden(viewModel.sourceScreen == .default && discoveryType == .discovery) .navigationTitle(CoreLocalization.Mainscreen.discovery) diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift index 836323072..e79afb8c1 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift @@ -14,6 +14,8 @@ public class DiscoveryWebviewViewModel: ObservableObject { @Published var courseDetails: CourseDetails? @Published private(set) var showProgress = false @Published var showError: Bool = false + @Published var webViewError: Bool = false + var errorMessage: String? { didSet { withAnimation { @@ -247,4 +249,8 @@ extension DiscoveryWebviewViewModel: WebViewNavigationDelegate { private func isValidAppURLScheme(_ url: URL) -> Bool { return url.scheme ?? "" == config.URIScheme } + + public func showWebViewError() { + self.webViewError = true + } } diff --git a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift index ad28e6938..a646d5108 100644 --- a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift +++ b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift @@ -10,7 +10,7 @@ import SwiftUI import Theme import Core -public enum ProgramViewType: Equatable { +public enum ProgramViewType: String, Equatable { case program case programDetail } @@ -18,7 +18,7 @@ public enum ProgramViewType: Equatable { public struct ProgramWebviewView: View { @State private var isLoading: Bool = true - @ObservedObject private var viewModel: ProgramWebviewViewModel + @StateObject private var viewModel: ProgramWebviewViewModel private var router: DiscoveryRouter private var viewType: ProgramViewType public var pathID: String @@ -42,71 +42,84 @@ public struct ProgramWebviewView: View { viewType: ProgramViewType = .program, pathID: String = "" ) { - self.viewModel = viewModel + self._viewModel = .init(wrappedValue: viewModel) self.router = router self.viewType = viewType self.pathID = pathID - - if let url = URL(string: URLString) { - viewModel.request = URLRequest(url: url) - } } public var body: some View { GeometryReader { proxy in - VStack(alignment: .center) { - WebView( - viewModel: .init( - url: URLString, - baseURL: "", - injections: [.colorInversionCss] - ), - isLoading: $isLoading, - refreshCookies: { - await viewModel.updateCookies( - force: true - ) - }, - navigationDelegate: viewModel - ) - .accessibilityIdentifier("program_webview") - - if isLoading || viewModel.showProgress || viewModel.updatingCookies { - HStack(alignment: .center) { - ProgressBar( - size: 40, - lineWidth: 8 - ) - .padding(.vertical, proxy.size.height / 2) - .accessibilityIdentifier("progress_bar") + ZStack(alignment: .center) { + VStack(alignment: .center) { + WebView( + viewModel: .init( + url: URLString, + baseURL: "", + injections: [.colorInversionCss] + ), + isLoading: $isLoading, + refreshCookies: { + await viewModel.updateCookies( + force: true + ) + }, + navigationDelegate: viewModel, + webViewType: viewType.rawValue + ) + .accessibilityIdentifier("program_webview") + + let shouldShowProgress = ( + isLoading || + viewModel.showProgress || + viewModel.updatingCookies + ) + if shouldShowProgress { + HStack(alignment: .center) { + ProgressBar( + size: 40, + lineWidth: 8 + ) + .padding(.vertical, proxy.size.height / 2) + .accessibilityIdentifier("progress_bar") + } + .frame(width: proxy.size.width, height: proxy.size.height) + } + + // MARK: - Show Error + if viewModel.showError { + VStack { + SnackBarView(message: viewModel.errorMessage) + } + .padding(.bottom, 20) + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } + } } - .frame(width: proxy.size.width, height: proxy.size.height) } - // MARK: - Show Error - if viewModel.showError { - VStack { - SnackBarView(message: viewModel.errorMessage) - } - .padding(.bottom, 20) - .transition(.move(edge: .bottom)) - .onAppear { - doAfter(Theme.Timeout.snackbarMessageLongTimeout) { - viewModel.errorMessage = nil + if viewModel.webViewError { + FullScreenErrorView( + type: viewModel.connectivity.isInternetAvaliable ? .generic : .noInternetWithReload + ) { + if viewModel.connectivity.isInternetAvaliable { + viewModel.webViewError = false + NotificationCenter.default.post( + name: Notification.Name(viewType.rawValue), + object: nil + ) } } } } - - // MARK: - Offline mode SnackBar - OfflineSnackBarView( - connectivity: viewModel.connectivity, - reloadAction: { - NotificationCenter.default.post( - name: .webviewReloadNotification, - object: nil - ) - }) + .onFirstAppear { + if let url = URL(string: URLString) { + viewModel.request = URLRequest(url: url) + } + } } .navigationBarHidden(viewType == .program) .navigationTitle(CoreLocalization.Mainscreen.programs) @@ -114,3 +127,23 @@ public struct ProgramWebviewView: View { .animation(.default, value: viewModel.showError) } } + +#if DEBUG +struct ProgramWebviewView_Previews: PreviewProvider { + static var previews: some View { + ProgramWebviewView( + viewModel: ProgramWebviewViewModel( + router: DiscoveryRouterMock(), + config: ConfigMock(), + interactor: DiscoveryInteractor.mock, + connectivity: Connectivity(), + analytics: DiscoveryAnalyticsMock(), + authInteractor: AuthInteractor.mock + ), + router: DiscoveryRouterMock(), + viewType: .program, + pathID: "" + ) + } +} +#endif diff --git a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift index ad0c89987..34ad47de2 100644 --- a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift +++ b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift @@ -14,6 +14,7 @@ public class ProgramWebviewViewModel: ObservableObject, WebviewCookiesUpdateProt @Published var courseDetails: CourseDetails? @Published private(set) var showProgress = false @Published var showError: Bool = false + @Published var webViewError: Bool = false @Published public var updatingCookies: Bool = false @Published public var cookiesReady: Bool = false @@ -235,4 +236,8 @@ extension ProgramWebviewViewModel: WebViewNavigationDelegate { private func isValidAppURLScheme(_ url: URL) -> Bool { return url.scheme ?? "" == config.URIScheme } + + public func showWebViewError() { + self.webViewError = true + } } From f6c9f4784b0f6027f6b1d1c5e39a76b78c187c98 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko <37253+rnr@users.noreply.github.com> Date: Fri, 5 Jul 2024 14:27:54 +0300 Subject: [PATCH 23/55] chore: added Settings button for dashboard type 'list' --- .../Dashboard/Presentation/ListDashboardView.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Dashboard/Dashboard/Presentation/ListDashboardView.swift b/Dashboard/Dashboard/Presentation/ListDashboardView.swift index d3787ebad..90a3e5eba 100644 --- a/Dashboard/Dashboard/Presentation/ListDashboardView.swift +++ b/Dashboard/Dashboard/Presentation/ListDashboardView.swift @@ -27,6 +27,7 @@ public struct ListDashboardView: View { @StateObject private var viewModel: ListDashboardViewModel private let router: DashboardRouter + private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } public init(viewModel: ListDashboardViewModel, router: DashboardRouter) { self._viewModel = StateObject(wrappedValue: { viewModel }()) @@ -103,6 +104,17 @@ public struct ListDashboardView: View { .frameLimit(width: proxy.size.width) }.accessibilityAction {} }.padding(.top, 8) + HStack { + Spacer() + Button(action: { + router.showSettings() + }, label: { + CoreAssets.settings.swiftUIImage.renderingMode(.template) + .foregroundColor(Theme.Colors.accentColor) + }) + } + .padding(.top, idiom == .pad ? 13 : 5) + .padding(.trailing, idiom == .pad ? 20 : 16) // MARK: - Offline mode SnackBar OfflineSnackBarView(connectivity: viewModel.connectivity, From 9901340ea443bb11df86c2d5973ae4bca588be10 Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Mon, 8 Jul 2024 15:25:41 +0500 Subject: [PATCH 24/55] chore: fix double notification routing and segment callback (#473) * chore: fix double notification routing and segment callback * refactor: address review feedback * refactor: address review feedback --- .../Listeners/BrazeListener.swift | 6 ++++++ .../Providers/BrazeProvider.swift | 5 ++++- .../PushNotificationsManager.swift | 10 +++++++++- 3 files changed, 19 insertions(+), 2 deletions(-) diff --git a/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift b/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift index b117843fc..0a78688e4 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift @@ -6,6 +6,7 @@ // import Foundation +import Swinject class BrazeListener: PushNotificationsListener { @@ -24,6 +25,11 @@ class BrazeListener: PushNotificationsListener { func didReceiveRemoteNotification(userInfo: [AnyHashable: Any]) { guard let dictionary = userInfo as? [String: AnyHashable], shouldListenNotification(userinfo: userInfo) else { return } + + if let segmentService = Container.shared.resolve(SegmentAnalyticsService.self) { + segmentService.analytics?.receivedRemoteNotification(userInfo: userInfo) + } + let link = PushLink(dictionary: dictionary) deepLinkManager.processLinkFromNotification(link) } diff --git a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift index 43ca0c780..3b93fec87 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift @@ -10,17 +10,20 @@ import SegmentBrazeUI import Swinject class BrazeProvider: PushNotificationsProvider { + func didRegisterWithDeviceToken(deviceToken: Data) { guard let segmentService = Container.shared.resolve(SegmentAnalyticsService.self) else { return } segmentService.analytics?.add( plugin: BrazeDestination( additionalConfiguration: { configuration in - configuration.logger.level = .debug + configuration.logger.level = .info }, additionalSetup: { braze in braze.notifications.register(deviceToken: deviceToken) } ) ) + + segmentService.analytics?.registeredForRemoteNotifications(deviceToken: deviceToken) } func didFailToRegisterForRemoteNotificationsWithError(error: Error) { diff --git a/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift b/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift index 1f6ab02b4..bd218ed02 100644 --- a/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift +++ b/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift @@ -29,6 +29,7 @@ class PushNotificationsManager: NSObject { private let deepLinkManager: DeepLinkManager private let storage: CoreStorage private let api: API + private var providers: [PushNotificationsProvider] = [] private var listeners: [PushNotificationsListener] = [] @@ -37,10 +38,16 @@ class PushNotificationsManager: NSObject { } // Init manager - public init(deepLinkManager: DeepLinkManager, storage: CoreStorage, api: API, config: ConfigProtocol) { + public init( + deepLinkManager: DeepLinkManager, + storage: CoreStorage, + api: API, + config: ConfigProtocol + ) { self.deepLinkManager = deepLinkManager self.storage = storage self.api = api + super.init() providers = providersFor(config: config) listeners = listenersFor(config: config) @@ -129,6 +136,7 @@ extension PushNotificationsManager: UNUserNotificationCenterDelegate { ) async -> UNNotificationPresentationOptions { if UIApplication.shared.applicationState == .active { didReceiveRemoteNotification(userInfo: notification.request.content.userInfo) + return [] } return [[.list, .banner, .sound]] From 5d99fba0656bac8982be191afec93a437f5ac693 Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Mon, 8 Jul 2024 16:29:14 +0500 Subject: [PATCH 25/55] feat: FullStory Integration and Analytics Implementation (#471) * feat: fullstory SDK integration and analytics implementation * chore: adding new screen events * chore: update user-defined flag to build settings based on config --------- Co-authored-by: Anton Yarmolenko --- .gitignore | 1 + .../Presentation/AuthorizationAnalytics.swift | 3 + .../Presentation/Login/SignInView.swift | 3 + .../Presentation/Login/SignInViewModel.swift | 8 +- .../Registration/SignUpView.swift | 3 + .../Registration/SignUpViewModel.swift | 7 ++ .../Presentation/Startup/StartupView.swift | 3 + .../Startup/StartupViewModel.swift | 4 + .../AuthorizationMock.generated.swift | 95 ++++++++++++++++ Core/Core.xcodeproj/project.pbxproj | 9 +- Core/Core/Analytics/CoreAnalytics.swift | 20 ++++ Core/Core/Configuration/Config/Config.swift | 1 + .../Config/FullStoryConfig.swift | 30 ++++++ .../Configuration/FullStoryConfigTests.swift | 57 ++++++++++ .../Course/Presentation/CourseAnalytics.swift | 3 + .../Presentation/Handouts/HandoutsView.swift | 4 +- Course/CourseTests/CourseMock.generated.swift | 96 +++++++++++++++++ .../DashboardMock.generated.swift | 76 +++++++++++++ .../Presentation/DiscoveryAnalytics.swift | 4 +- .../DiscoveryWebviewViewModel.swift | 2 +- .../DiscoveryMock.generated.swift | 96 +++++++++++++++-- .../DiscussionMock.generated.swift | 76 +++++++++++++ Documentation/Theming_implementation.md | 2 + OpenEdX.xcodeproj/project.pbxproj | 54 ++++++++++ OpenEdX/DI/AppAssembly.swift | 4 + .../AnalyticsManager/AnalyticsManager.swift | 80 +++++++++++--- .../FirebaseAnalyticsService.swift | 4 + .../FullStoryAnalyticsService.swift | 25 +++++ .../SegmentAnalyticsService.swift | 4 + .../EditProfile/EditProfileView.swift | 3 + .../EditProfile/EditProfileViewModel.swift | 4 + .../Presentation/ProfileAnalytics.swift | 6 +- .../Settings/SettingsViewModel.swift | 2 +- .../ProfileTests/ProfileMock.generated.swift | 101 +++++++++++++++++- config_script/process_config.py | 9 ++ config_script/whitelabel.py | 98 ++++++++++++++++- 36 files changed, 959 insertions(+), 38 deletions(-) create mode 100644 Core/Core/Configuration/Config/FullStoryConfig.swift create mode 100644 Core/CoreTests/Configuration/FullStoryConfigTests.swift create mode 100644 OpenEdX/Managers/FullStoryAnalyticsService/FullStoryAnalyticsService.swift diff --git a/.gitignore b/.gitignore index 274b62ec6..75a5357e4 100644 --- a/.gitignore +++ b/.gitignore @@ -122,3 +122,4 @@ default_config/ I18N/ *.lproj/ !en.lproj/ +/config_script/__pycache__ diff --git a/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift b/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift index 00cb384a5..9fcd13b69 100644 --- a/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift +++ b/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift @@ -6,6 +6,7 @@ // import Foundation +import Core public enum AuthMethod: Equatable { case password @@ -40,6 +41,7 @@ public protocol AuthorizationAnalytics { func forgotPasswordClicked() func resetPasswordClicked() func resetPassword(success: Bool) + func authTrackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue) } #if DEBUG @@ -54,5 +56,6 @@ class AuthorizationAnalyticsMock: AuthorizationAnalytics { public func forgotPasswordClicked() {} public func resetPasswordClicked() {} public func resetPassword(success: Bool) {} + public func authTrackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue) {} } #endif diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index 20bfcb659..4369f2fab 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -212,6 +212,9 @@ public struct SignInView: View { .hideNavigationBar() .ignoresSafeArea(.all, edges: .horizontal) .background(Theme.Colors.background.ignoresSafeArea(.all)) + .onFirstAppear{ + viewModel.trackScreenEvent() + } } @ViewBuilder diff --git a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift index ef573e047..5a87151f5 100644 --- a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift +++ b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift @@ -147,5 +147,11 @@ public class SignInViewModel: ObservableObject { func trackForgotPasswordClicked() { analytics.forgotPasswordClicked() } - + + func trackScreenEvent() { + analytics.authTrackScreenEvent( + .logistrationSignIn, + biValue: .logistrationSignIn + ) + } } diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index 2401ad846..7ec2c8ba5 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -196,6 +196,9 @@ public struct SignUpView: View { .ignoresSafeArea(.all, edges: .horizontal) .background(Theme.Colors.background.ignoresSafeArea(.all)) .hideNavigationBar() + .onFirstAppear{ + viewModel.trackScreenEvent() + } } } diff --git a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift index 5c41badef..8b2fe1b22 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift @@ -213,4 +213,11 @@ public class SignUpViewModel: ObservableObject { func trackCreateAccountClicked() { analytics.createAccountClicked() } + + func trackScreenEvent() { + analytics.authTrackScreenEvent( + .logistrationRegister, + biValue: .logistrationRegister + ) + } } diff --git a/Authorization/Authorization/Presentation/Startup/StartupView.swift b/Authorization/Authorization/Presentation/Startup/StartupView.swift index a13c3e3c8..88551d824 100644 --- a/Authorization/Authorization/Presentation/Startup/StartupView.swift +++ b/Authorization/Authorization/Presentation/Startup/StartupView.swift @@ -126,6 +126,9 @@ public struct StartupView: View { .onTapGesture { UIApplication.shared.endEditing() } + .onFirstAppear { + viewModel.trackScreenEvent() + } } } diff --git a/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift b/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift index 650ae5f7f..b4cf50091 100644 --- a/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift +++ b/Authorization/Authorization/Presentation/Startup/StartupViewModel.swift @@ -33,4 +33,8 @@ public class StartupViewModel: ObservableObject { analytics.trackEvent(.logistrationExploreAllCourses, biValue: .logistrationExploreAllCourses) } } + + func trackScreenEvent() { + analytics.trackScreenEvent(.logistration, biValue: .logistration) + } } diff --git a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift index f17939bd4..34ce42889 100644 --- a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift +++ b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift @@ -569,6 +569,12 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { perform?(`success`) } + open func authTrackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_authTrackScreenEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_authTrackScreenEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void + perform?(`event`, `biValue`) + } + fileprivate enum MethodType { case m_identify__id_idusername_usernameemail_email(Parameter, Parameter, Parameter) @@ -581,6 +587,7 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { case m_forgotPasswordClicked case m_resetPasswordClicked case m_resetPassword__success_success(Parameter) + case m_authTrackScreenEvent__eventbiValue_biValue(Parameter, Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { @@ -617,6 +624,12 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { 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_authTrackScreenEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_authTrackScreenEvent__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 } } @@ -633,6 +646,7 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { case .m_forgotPasswordClicked: return 0 case .m_resetPasswordClicked: return 0 case let .m_resetPassword__success_success(p0): return p0.intValue + case let .m_authTrackScreenEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue } } func assertionName() -> String { @@ -647,6 +661,7 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { case .m_forgotPasswordClicked: return ".forgotPasswordClicked()" case .m_resetPasswordClicked: return ".resetPasswordClicked()" case .m_resetPassword__success_success: return ".resetPassword(success:)" + case .m_authTrackScreenEvent__eventbiValue_biValue: return ".authTrackScreenEvent(_:biValue:)" } } } @@ -675,6 +690,7 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { public static func forgotPasswordClicked() -> Verify { return Verify(method: .m_forgotPasswordClicked)} 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 static func authTrackScreenEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_authTrackScreenEvent__eventbiValue_biValue(`event`, `biValue`))} } public struct Perform { @@ -711,6 +727,9 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { public static func resetPassword(success: Parameter, perform: @escaping (Bool) -> Void) -> Perform { return Perform(method: .m_resetPassword__success_success(`success`), performs: perform) } + public static func authTrackScreenEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_authTrackScreenEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } } public func given(_ method: Given) { @@ -1964,6 +1983,18 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { perform?(`event`, `biValue`, `parameters`) } + open func trackScreenEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + addInvocation(.m_trackScreenEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackScreenEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, [String: Any]?) -> Void + perform?(`event`, `parameters`) + } + + open func trackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { + addInvocation(.m_trackScreenEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackScreenEvent__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 @@ -1988,14 +2019,30 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { perform?(`event`, `biValue`) } + open func trackScreenEvent(_ event: AnalyticsEvent) { + addInvocation(.m_trackScreenEvent__event(Parameter.value(`event`))) + let perform = methodPerformValue(.m_trackScreenEvent__event(Parameter.value(`event`))) as? (AnalyticsEvent) -> Void + perform?(`event`) + } + + open func trackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_trackScreenEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_trackScreenEvent__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_trackScreenEvent__eventparameters_parameters(Parameter, Parameter<[String: Any]?>) + case m_trackScreenEvent__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) + case m_trackScreenEvent__event(Parameter) + case m_trackScreenEvent__eventbiValue_biValue(Parameter, Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { @@ -2012,6 +2059,19 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) return Matcher.ComparisonResult(results) + case (.m_trackScreenEvent__eventparameters_parameters(let lhsEvent, let lhsParameters), .m_trackScreenEvent__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_trackScreenEvent__eventbiValue_biValueparameters_parameters(let lhsEvent, let lhsBivalue, let lhsParameters), .m_trackScreenEvent__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")) @@ -2038,6 +2098,17 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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) + + case (.m_trackScreenEvent__event(let lhsEvent), .m_trackScreenEvent__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_trackScreenEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_trackScreenEvent__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 } } @@ -2046,20 +2117,28 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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_trackScreenEvent__eventparameters_parameters(p0, p1): return p0.intValue + p1.intValue + case let .m_trackScreenEvent__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 + case let .m_trackScreenEvent__event(p0): return p0.intValue + case let .m_trackScreenEvent__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_trackScreenEvent__eventparameters_parameters: return ".trackScreenEvent(_:parameters:)" + case .m_trackScreenEvent__eventbiValue_biValueparameters_parameters: return ".trackScreenEvent(_: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:)" + case .m_trackScreenEvent__event: return ".trackScreenEvent(_:)" + case .m_trackScreenEvent__eventbiValue_biValue: return ".trackScreenEvent(_:biValue:)" } } } @@ -2080,10 +2159,14 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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 trackScreenEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackScreenEvent__eventparameters_parameters(`event`, `parameters`))} + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackScreenEvent__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 static func trackScreenEvent(_ event: Parameter) -> Verify { return Verify(method: .m_trackScreenEvent__event(`event`))} + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_trackScreenEvent__eventbiValue_biValue(`event`, `biValue`))} } public struct Perform { @@ -2096,6 +2179,12 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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 trackScreenEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__eventparameters_parameters(`event`, `parameters`), performs: perform) + } + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__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) } @@ -2108,6 +2197,12 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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 static func trackScreenEvent(_ event: Parameter, perform: @escaping (AnalyticsEvent) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__event(`event`), performs: perform) + } + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } } public func given(_ method: Given) { diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 3e59ea038..d52896f50 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -144,6 +144,8 @@ 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 */; }; + 14D912D92C2553C70077CCCE /* FullStoryConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D912D82C2553C70077CCCE /* FullStoryConfig.swift */; }; + 14D912DB2C257E9E0077CCCE /* FullStoryConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D912DA2C257E9E0077CCCE /* FullStoryConfigTests.swift */; }; 9784D47E2BF7762800AFEFFF /* FullScreenErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9784D47D2BF7762800AFEFFF /* FullScreenErrorView.swift */; }; A51CDBE72B6D21F2009B6D4E /* SegmentConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51CDBE62B6D21F2009B6D4E /* SegmentConfig.swift */; }; A53A32352B233DEC005FE38A /* ThemeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A32342B233DEC005FE38A /* ThemeConfig.swift */; }; @@ -335,6 +337,8 @@ 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 = ""; }; + 14D912D82C2553C70077CCCE /* FullStoryConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullStoryConfig.swift; sourceTree = ""; }; + 14D912DA2C257E9E0077CCCE /* FullStoryConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullStoryConfigTests.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; }; @@ -867,6 +871,7 @@ 02CA59832BD7DDBE00D517AA /* DashboardConfig.swift */, A53A32342B233DEC005FE38A /* ThemeConfig.swift */, E0D586192B2FF74C009B4BA7 /* DiscoveryConfig.swift */, + 14D912D82C2553C70077CCCE /* FullStoryConfig.swift */, ); path = Config; sourceTree = ""; @@ -884,6 +889,7 @@ children = ( E09179FC2B0F204D002AB695 /* ConfigTests.swift */, BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */, + 14D912DA2C257E9E0077CCCE /* FullStoryConfigTests.swift */, ); path = Configuration; sourceTree = ""; @@ -1071,6 +1077,7 @@ buildActionMask = 2147483647; files = ( BAD9CA422B2B140100DE790A /* AgreementConfigTests.swift in Sources */, + 14D912DB2C257E9E0077CCCE /* FullStoryConfigTests.swift in Sources */, E09179FD2B0F204E002AB695 /* ConfigTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1137,6 +1144,7 @@ 06078B702BA49C3100576798 /* Dictionary+JSON.swift in Sources */, 9784D47E2BF7762800AFEFFF /* FullScreenErrorView.swift in Sources */, 027BD3AE2909475000392132 /* KeyboardScrollerOptions.swift in Sources */, + 14D912D92C2553C70077CCCE /* FullStoryConfig.swift in Sources */, BAFB99922B14E23D007D09F9 /* AppleSignInConfig.swift in Sources */, 141F1D302B7328D4009E81EB /* WebviewCookiesUpdateProtocol.swift in Sources */, 064987992B4D69FF0071642A /* WebViewScriptInjectionProtocol.swift in Sources */, @@ -1223,7 +1231,6 @@ 02F164372902A9EB0090DDEF /* StringExtension.swift in Sources */, 0231CDBE2922422D00032416 /* CSSInjector.swift in Sources */, 064987982B4D69FF0071642A /* CSSInjectionProtocol.swift in Sources */, - BAAD62C62AFCF00B000E6103 /* CustomDisclosureGroup.swift in Sources */, 029EE3ED2BF6650500F64F33 /* Bundle.swift in Sources */, BADB3F5B2AD6EC56004D5CFA /* ResultExtension.swift in Sources */, 0236961928F9A26900EEF206 /* AuthRepository.swift in Sources */, diff --git a/Core/Core/Analytics/CoreAnalytics.swift b/Core/Core/Analytics/CoreAnalytics.swift index c7d1eca7c..137bf094f 100644 --- a/Core/Core/Analytics/CoreAnalytics.swift +++ b/Core/Core/Analytics/CoreAnalytics.swift @@ -11,6 +11,8 @@ import Foundation public protocol CoreAnalytics { func trackEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) + func trackScreenEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) + func trackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) func appreview(_ event: AnalyticsEvent, biValue: EventBIValue, action: String?, rating: Int?) func videoQualityChanged( _ event: AnalyticsEvent, @@ -28,6 +30,14 @@ public extension CoreAnalytics { func trackEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { trackEvent(event, biValue: biValue, parameters: nil) } + + func trackScreenEvent(_ event: AnalyticsEvent) { + trackScreenEvent(event, parameters: nil) + } + + func trackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + trackScreenEvent(event, biValue: biValue, parameters: nil) + } } #if DEBUG @@ -35,6 +45,8 @@ 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 trackScreenEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) {} + public func trackScreenEvent(_ 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, @@ -124,6 +136,10 @@ public enum AnalyticsEvent: String { case whatnewPopup = "WhatsNew:Pop up Viewed" case whatnewDone = "WhatsNew:Done" case whatnewClose = "WhatsNew:Close" + case logistration = "Logistration" + case logistrationSignIn = "Logistration:Sign In" + case logistrationRegister = "Logistration:Register" + case profileEdit = "Profile:Edit Profile" } public enum EventBIValue: String { @@ -205,6 +221,10 @@ public enum EventBIValue: String { 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" + case logistration = "edx.bi.app.logistration" + case logistrationSignIn = "edx.bi.app.logistration.signin" + case logistrationRegister = "edx.bi.app.logistration.register" + case profileEdit = "edx.bi.app.profile.edit" } public struct EventParamKey { diff --git a/Core/Core/Configuration/Config/Config.swift b/Core/Core/Configuration/Config/Config.swift index bd75f3f89..be5fd1941 100644 --- a/Core/Core/Configuration/Config/Config.swift +++ b/Core/Core/Configuration/Config/Config.swift @@ -31,6 +31,7 @@ public protocol ConfigProtocol { var segment: SegmentConfig { get } var program: DiscoveryConfig { get } var URIScheme: String { get } + var fullStory: FullStoryConfig { get } } public enum TokenType: String { diff --git a/Core/Core/Configuration/Config/FullStoryConfig.swift b/Core/Core/Configuration/Config/FullStoryConfig.swift new file mode 100644 index 000000000..4e1181dc3 --- /dev/null +++ b/Core/Core/Configuration/Config/FullStoryConfig.swift @@ -0,0 +1,30 @@ +// +// FullStoryConfig.swift +// Core +// +// Created by Saeed Bashir on 6/21/24. +// + +import Foundation + +private enum Keys: String, RawStringExtractable { + case enabled = "ENABLED" + case orgID = "ORG_ID" +} + +public final class FullStoryConfig { + public var enabled: Bool = false + public var orgID: String = "" + + init(dictionary: [String: AnyObject]) { + orgID = dictionary[Keys.orgID] as? String ?? "" + enabled = !orgID.isEmpty && dictionary[Keys.enabled] as? Bool ?? false + } +} + +private let configKey = "FULLSTORY" +extension Config { + public var fullStory: FullStoryConfig { + FullStoryConfig(dictionary: self[configKey] as? [String: AnyObject] ?? [:]) + } +} diff --git a/Core/CoreTests/Configuration/FullStoryConfigTests.swift b/Core/CoreTests/Configuration/FullStoryConfigTests.swift new file mode 100644 index 000000000..092d9a5ee --- /dev/null +++ b/Core/CoreTests/Configuration/FullStoryConfigTests.swift @@ -0,0 +1,57 @@ +// +// FullStoryConfigTests.swift +// CoreTests +// +// Created by Saeed Bashir on 6/21/24. +// + +import XCTest +@testable import Core + +class FullStoryConfigTests: XCTestCase { + + func testNoFullStoryConfig() { + let config = Config(properties: [:]) + XCTAssertFalse(config.fullStory.enabled) + } + + func testFullStoryEnabled() { + let configDictionary = [ + "FULLSTORY": [ + "ENABLED": true, + "ORG_ID": "org_id" + ] + ] + + let config = Config(properties: configDictionary) + + XCTAssertTrue(config.fullStory.enabled) + XCTAssertNotNil(config.fullStory.orgID) + } + + func testFullStoryDisabled() { + let configDictionary = [ + "FULLSTORY": [ + "ENABLED": false, + "ORG_ID": "org_id" + ] + ] + + let config = Config(properties: configDictionary) + + XCTAssertFalse(config.fullStory.enabled) + XCTAssertNotNil(config.fullStory.orgID) + } + + func testFullStoryMissingORGID() { + let configDictionary = [ + "FULLSTORY": [ + "ENABLED": true + ] + ] + + let config = Config(properties: configDictionary) + XCTAssertFalse(config.fullStory.enabled) + XCTAssertEqual(config.fullStory.orgID, "") + } +} diff --git a/Course/Course/Presentation/CourseAnalytics.swift b/Course/Course/Presentation/CourseAnalytics.swift index bc531c152..612f9249c 100644 --- a/Course/Course/Presentation/CourseAnalytics.swift +++ b/Course/Course/Presentation/CourseAnalytics.swift @@ -87,6 +87,8 @@ public protocol CourseAnalytics { snackbar: SnackbarType ) func trackCourseEvent(_ event: AnalyticsEvent, biValue: EventBIValue, courseID: String) + func trackCourseScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue, courseID: String) + func plsEvent( _ event: AnalyticsEvent, bivalue: EventBIValue, @@ -164,6 +166,7 @@ class CourseAnalyticsMock: CourseAnalytics { snackbar: SnackbarType ) {} public func trackCourseEvent(_ event: AnalyticsEvent, biValue: EventBIValue, courseID: String) {} + public func trackCourseScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue, courseID: String) {} public func plsEvent( _ event: AnalyticsEvent, bivalue: EventBIValue, diff --git a/Course/Course/Presentation/Handouts/HandoutsView.swift b/Course/Course/Presentation/Handouts/HandoutsView.swift index de8bb631f..c9f7460de 100644 --- a/Course/Course/Presentation/Handouts/HandoutsView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsView.swift @@ -54,7 +54,7 @@ struct HandoutsView: View { announcements: nil, router: viewModel.router, cssInjector: viewModel.cssInjector) - viewModel.analytics.trackCourseEvent( + viewModel.analytics.trackCourseScreenEvent( .courseHandouts, biValue: .courseHandouts, courseID: courseID @@ -68,7 +68,7 @@ struct HandoutsView: View { announcements: viewModel.updates, router: viewModel.router, cssInjector: viewModel.cssInjector) - viewModel.analytics.trackCourseEvent( + viewModel.analytics.trackCourseScreenEvent( .courseAnnouncement, biValue: .courseAnnouncement, courseID: courseID diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index 855432bc8..067556faf 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -1171,6 +1171,18 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { perform?(`event`, `biValue`, `parameters`) } + open func trackScreenEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + addInvocation(.m_trackScreenEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackScreenEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, [String: Any]?) -> Void + perform?(`event`, `parameters`) + } + + open func trackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { + addInvocation(.m_trackScreenEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackScreenEvent__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 @@ -1195,14 +1207,30 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { perform?(`event`, `biValue`) } + open func trackScreenEvent(_ event: AnalyticsEvent) { + addInvocation(.m_trackScreenEvent__event(Parameter.value(`event`))) + let perform = methodPerformValue(.m_trackScreenEvent__event(Parameter.value(`event`))) as? (AnalyticsEvent) -> Void + perform?(`event`) + } + + open func trackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_trackScreenEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_trackScreenEvent__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_trackScreenEvent__eventparameters_parameters(Parameter, Parameter<[String: Any]?>) + case m_trackScreenEvent__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) + case m_trackScreenEvent__event(Parameter) + case m_trackScreenEvent__eventbiValue_biValue(Parameter, Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { @@ -1219,6 +1247,19 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) return Matcher.ComparisonResult(results) + case (.m_trackScreenEvent__eventparameters_parameters(let lhsEvent, let lhsParameters), .m_trackScreenEvent__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_trackScreenEvent__eventbiValue_biValueparameters_parameters(let lhsEvent, let lhsBivalue, let lhsParameters), .m_trackScreenEvent__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")) @@ -1245,6 +1286,17 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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) + + case (.m_trackScreenEvent__event(let lhsEvent), .m_trackScreenEvent__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_trackScreenEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_trackScreenEvent__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 } } @@ -1253,20 +1305,28 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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_trackScreenEvent__eventparameters_parameters(p0, p1): return p0.intValue + p1.intValue + case let .m_trackScreenEvent__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 + case let .m_trackScreenEvent__event(p0): return p0.intValue + case let .m_trackScreenEvent__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_trackScreenEvent__eventparameters_parameters: return ".trackScreenEvent(_:parameters:)" + case .m_trackScreenEvent__eventbiValue_biValueparameters_parameters: return ".trackScreenEvent(_: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:)" + case .m_trackScreenEvent__event: return ".trackScreenEvent(_:)" + case .m_trackScreenEvent__eventbiValue_biValue: return ".trackScreenEvent(_:biValue:)" } } } @@ -1287,10 +1347,14 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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 trackScreenEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackScreenEvent__eventparameters_parameters(`event`, `parameters`))} + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackScreenEvent__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 static func trackScreenEvent(_ event: Parameter) -> Verify { return Verify(method: .m_trackScreenEvent__event(`event`))} + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_trackScreenEvent__eventbiValue_biValue(`event`, `biValue`))} } public struct Perform { @@ -1303,6 +1367,12 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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 trackScreenEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__eventparameters_parameters(`event`, `parameters`), performs: perform) + } + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__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) } @@ -1315,6 +1385,12 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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 static func trackScreenEvent(_ event: Parameter, perform: @escaping (AnalyticsEvent) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__event(`event`), performs: perform) + } + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } } public func given(_ method: Given) { @@ -1542,6 +1618,12 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { perform?(`event`, `biValue`, `courseID`) } + open func trackCourseScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue, courseID: String) { + addInvocation(.m_trackCourseScreenEvent__eventbiValue_biValuecourseID_courseID(Parameter.value(`event`), Parameter.value(`biValue`), Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_trackCourseScreenEvent__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 @@ -1592,6 +1674,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { 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_trackCourseScreenEvent__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) @@ -1731,6 +1814,13 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) return Matcher.ComparisonResult(results) + case (.m_trackCourseScreenEvent__eventbiValue_biValuecourseID_courseID(let lhsEvent, let lhsBivalue, let lhsCourseid), .m_trackCourseScreenEvent__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")) @@ -1794,6 +1884,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { 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_trackCourseScreenEvent__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 @@ -1821,6 +1912,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { 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_trackCourseScreenEvent__eventbiValue_biValuecourseID_courseID: return ".trackCourseScreenEvent(_: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:)" @@ -1862,6 +1954,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { 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 trackCourseScreenEvent(_ event: Parameter, biValue: Parameter, courseID: Parameter) -> Verify { return Verify(method: .m_trackCourseScreenEvent__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`))} @@ -1927,6 +2020,9 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { 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 trackCourseScreenEvent(_ event: Parameter, biValue: Parameter, courseID: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue, String) -> Void) -> Perform { + return Perform(method: .m_trackCourseScreenEvent__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) } diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index 5dd6af2cc..82ae9be00 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -1171,6 +1171,18 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { perform?(`event`, `biValue`, `parameters`) } + open func trackScreenEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + addInvocation(.m_trackScreenEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackScreenEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, [String: Any]?) -> Void + perform?(`event`, `parameters`) + } + + open func trackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { + addInvocation(.m_trackScreenEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackScreenEvent__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 @@ -1195,14 +1207,30 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { perform?(`event`, `biValue`) } + open func trackScreenEvent(_ event: AnalyticsEvent) { + addInvocation(.m_trackScreenEvent__event(Parameter.value(`event`))) + let perform = methodPerformValue(.m_trackScreenEvent__event(Parameter.value(`event`))) as? (AnalyticsEvent) -> Void + perform?(`event`) + } + + open func trackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_trackScreenEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_trackScreenEvent__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_trackScreenEvent__eventparameters_parameters(Parameter, Parameter<[String: Any]?>) + case m_trackScreenEvent__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) + case m_trackScreenEvent__event(Parameter) + case m_trackScreenEvent__eventbiValue_biValue(Parameter, Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { @@ -1219,6 +1247,19 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) return Matcher.ComparisonResult(results) + case (.m_trackScreenEvent__eventparameters_parameters(let lhsEvent, let lhsParameters), .m_trackScreenEvent__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_trackScreenEvent__eventbiValue_biValueparameters_parameters(let lhsEvent, let lhsBivalue, let lhsParameters), .m_trackScreenEvent__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")) @@ -1245,6 +1286,17 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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) + + case (.m_trackScreenEvent__event(let lhsEvent), .m_trackScreenEvent__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_trackScreenEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_trackScreenEvent__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 } } @@ -1253,20 +1305,28 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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_trackScreenEvent__eventparameters_parameters(p0, p1): return p0.intValue + p1.intValue + case let .m_trackScreenEvent__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 + case let .m_trackScreenEvent__event(p0): return p0.intValue + case let .m_trackScreenEvent__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_trackScreenEvent__eventparameters_parameters: return ".trackScreenEvent(_:parameters:)" + case .m_trackScreenEvent__eventbiValue_biValueparameters_parameters: return ".trackScreenEvent(_: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:)" + case .m_trackScreenEvent__event: return ".trackScreenEvent(_:)" + case .m_trackScreenEvent__eventbiValue_biValue: return ".trackScreenEvent(_:biValue:)" } } } @@ -1287,10 +1347,14 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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 trackScreenEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackScreenEvent__eventparameters_parameters(`event`, `parameters`))} + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackScreenEvent__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 static func trackScreenEvent(_ event: Parameter) -> Verify { return Verify(method: .m_trackScreenEvent__event(`event`))} + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_trackScreenEvent__eventbiValue_biValue(`event`, `biValue`))} } public struct Perform { @@ -1303,6 +1367,12 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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 trackScreenEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__eventparameters_parameters(`event`, `parameters`), performs: perform) + } + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__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) } @@ -1315,6 +1385,12 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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 static func trackScreenEvent(_ event: Parameter, perform: @escaping (AnalyticsEvent) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__event(`event`), performs: perform) + } + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } } public func given(_ method: Given) { diff --git a/Discovery/Discovery/Presentation/DiscoveryAnalytics.swift b/Discovery/Discovery/Presentation/DiscoveryAnalytics.swift index bfc9e7075..db2119038 100644 --- a/Discovery/Discovery/Presentation/DiscoveryAnalytics.swift +++ b/Discovery/Discovery/Presentation/DiscoveryAnalytics.swift @@ -18,7 +18,7 @@ public protocol DiscoveryAnalytics { 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) + func discoveryScreenEvent(event: AnalyticsEvent, biValue: EventBIValue) } #if DEBUG @@ -31,6 +31,6 @@ class DiscoveryAnalyticsMock: DiscoveryAnalytics { 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) {} + public func discoveryScreenEvent(event: AnalyticsEvent, biValue: EventBIValue) {} } #endif diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift index e79afb8c1..b9f2bb515 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift @@ -189,7 +189,7 @@ extension DiscoveryWebviewViewModel: WebViewNavigationDelegate { case .programDetail: guard let pathID = programDetailPathId(from: url) else { return false } - analytics.discoveryEvent(event: .discoveryProgramInfo, biValue: .discoveryProgramInfo) + analytics.discoveryScreenEvent(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 7b7d2c10a..1bcdcff78 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -1171,6 +1171,18 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { perform?(`event`, `biValue`, `parameters`) } + open func trackScreenEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + addInvocation(.m_trackScreenEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackScreenEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, [String: Any]?) -> Void + perform?(`event`, `parameters`) + } + + open func trackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { + addInvocation(.m_trackScreenEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackScreenEvent__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 @@ -1195,14 +1207,30 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { perform?(`event`, `biValue`) } + open func trackScreenEvent(_ event: AnalyticsEvent) { + addInvocation(.m_trackScreenEvent__event(Parameter.value(`event`))) + let perform = methodPerformValue(.m_trackScreenEvent__event(Parameter.value(`event`))) as? (AnalyticsEvent) -> Void + perform?(`event`) + } + + open func trackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_trackScreenEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_trackScreenEvent__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_trackScreenEvent__eventparameters_parameters(Parameter, Parameter<[String: Any]?>) + case m_trackScreenEvent__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) + case m_trackScreenEvent__event(Parameter) + case m_trackScreenEvent__eventbiValue_biValue(Parameter, Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { @@ -1219,6 +1247,19 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) return Matcher.ComparisonResult(results) + case (.m_trackScreenEvent__eventparameters_parameters(let lhsEvent, let lhsParameters), .m_trackScreenEvent__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_trackScreenEvent__eventbiValue_biValueparameters_parameters(let lhsEvent, let lhsBivalue, let lhsParameters), .m_trackScreenEvent__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")) @@ -1245,6 +1286,17 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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) + + case (.m_trackScreenEvent__event(let lhsEvent), .m_trackScreenEvent__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_trackScreenEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_trackScreenEvent__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 } } @@ -1253,20 +1305,28 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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_trackScreenEvent__eventparameters_parameters(p0, p1): return p0.intValue + p1.intValue + case let .m_trackScreenEvent__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 + case let .m_trackScreenEvent__event(p0): return p0.intValue + case let .m_trackScreenEvent__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_trackScreenEvent__eventparameters_parameters: return ".trackScreenEvent(_:parameters:)" + case .m_trackScreenEvent__eventbiValue_biValueparameters_parameters: return ".trackScreenEvent(_: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:)" + case .m_trackScreenEvent__event: return ".trackScreenEvent(_:)" + case .m_trackScreenEvent__eventbiValue_biValue: return ".trackScreenEvent(_:biValue:)" } } } @@ -1287,10 +1347,14 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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 trackScreenEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackScreenEvent__eventparameters_parameters(`event`, `parameters`))} + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackScreenEvent__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 static func trackScreenEvent(_ event: Parameter) -> Verify { return Verify(method: .m_trackScreenEvent__event(`event`))} + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_trackScreenEvent__eventbiValue_biValue(`event`, `biValue`))} } public struct Perform { @@ -1303,6 +1367,12 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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 trackScreenEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__eventparameters_parameters(`event`, `parameters`), performs: perform) + } + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__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) } @@ -1315,6 +1385,12 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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 static func trackScreenEvent(_ event: Parameter, perform: @escaping (AnalyticsEvent) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__event(`event`), performs: perform) + } + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } } public func given(_ method: Given) { @@ -1482,9 +1558,9 @@ open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { 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 + open func discoveryScreenEvent(event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_discoveryScreenEvent__event_eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_discoveryScreenEvent__event_eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void perform?(`event`, `biValue`) } @@ -1498,7 +1574,7 @@ open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { 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) + case m_discoveryScreenEvent__event_eventbiValue_biValue(Parameter, Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { @@ -1547,7 +1623,7 @@ open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { 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)): + case (.m_discoveryScreenEvent__event_eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_discoveryScreenEvent__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")) @@ -1566,7 +1642,7 @@ open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { 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 + case let .m_discoveryScreenEvent__event_eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue } } func assertionName() -> String { @@ -1579,7 +1655,7 @@ open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { 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:)" + case .m_discoveryScreenEvent__event_eventbiValue_biValue: return ".discoveryScreenEvent(event:biValue:)" } } } @@ -1606,7 +1682,7 @@ open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { 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 static func discoveryScreenEvent(event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_discoveryScreenEvent__event_eventbiValue_biValue(`event`, `biValue`))} } public struct Perform { @@ -1637,8 +1713,8 @@ open class DiscoveryAnalyticsMock: DiscoveryAnalytics, Mock { 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 static func discoveryScreenEvent(event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_discoveryScreenEvent__event_eventbiValue_biValue(`event`, `biValue`), performs: perform) } } diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index 85aa084a5..82022c6aa 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -1171,6 +1171,18 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { perform?(`event`, `biValue`, `parameters`) } + open func trackScreenEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + addInvocation(.m_trackScreenEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackScreenEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, [String: Any]?) -> Void + perform?(`event`, `parameters`) + } + + open func trackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { + addInvocation(.m_trackScreenEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackScreenEvent__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 @@ -1195,14 +1207,30 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { perform?(`event`, `biValue`) } + open func trackScreenEvent(_ event: AnalyticsEvent) { + addInvocation(.m_trackScreenEvent__event(Parameter.value(`event`))) + let perform = methodPerformValue(.m_trackScreenEvent__event(Parameter.value(`event`))) as? (AnalyticsEvent) -> Void + perform?(`event`) + } + + open func trackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_trackScreenEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_trackScreenEvent__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_trackScreenEvent__eventparameters_parameters(Parameter, Parameter<[String: Any]?>) + case m_trackScreenEvent__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) + case m_trackScreenEvent__event(Parameter) + case m_trackScreenEvent__eventbiValue_biValue(Parameter, Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { @@ -1219,6 +1247,19 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) return Matcher.ComparisonResult(results) + case (.m_trackScreenEvent__eventparameters_parameters(let lhsEvent, let lhsParameters), .m_trackScreenEvent__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_trackScreenEvent__eventbiValue_biValueparameters_parameters(let lhsEvent, let lhsBivalue, let lhsParameters), .m_trackScreenEvent__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")) @@ -1245,6 +1286,17 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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) + + case (.m_trackScreenEvent__event(let lhsEvent), .m_trackScreenEvent__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_trackScreenEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_trackScreenEvent__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 } } @@ -1253,20 +1305,28 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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_trackScreenEvent__eventparameters_parameters(p0, p1): return p0.intValue + p1.intValue + case let .m_trackScreenEvent__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 + case let .m_trackScreenEvent__event(p0): return p0.intValue + case let .m_trackScreenEvent__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_trackScreenEvent__eventparameters_parameters: return ".trackScreenEvent(_:parameters:)" + case .m_trackScreenEvent__eventbiValue_biValueparameters_parameters: return ".trackScreenEvent(_: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:)" + case .m_trackScreenEvent__event: return ".trackScreenEvent(_:)" + case .m_trackScreenEvent__eventbiValue_biValue: return ".trackScreenEvent(_:biValue:)" } } } @@ -1287,10 +1347,14 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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 trackScreenEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackScreenEvent__eventparameters_parameters(`event`, `parameters`))} + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackScreenEvent__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 static func trackScreenEvent(_ event: Parameter) -> Verify { return Verify(method: .m_trackScreenEvent__event(`event`))} + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_trackScreenEvent__eventbiValue_biValue(`event`, `biValue`))} } public struct Perform { @@ -1303,6 +1367,12 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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 trackScreenEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__eventparameters_parameters(`event`, `parameters`), performs: perform) + } + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__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) } @@ -1315,6 +1385,12 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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 static func trackScreenEvent(_ event: Parameter, perform: @escaping (AnalyticsEvent) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__event(`event`), performs: perform) + } + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } } public func given(_ method: Given) { diff --git a/Documentation/Theming_implementation.md b/Documentation/Theming_implementation.md index 33e5b577d..e9d5b37dd 100644 --- a/Documentation/Theming_implementation.md +++ b/Documentation/Theming_implementation.md @@ -45,9 +45,11 @@ project_config: config1: # Build Configuration name in project app_bundle_id: "bundle.id.app.new1" # Bundle ID to be set product_name: "Mobile App Name1" # App Name to be set + env_config: 'prod' # env name for this configuration. possible values: prod/dev/stage (values which config_settings.yaml defines) config2: # Build Configuration name in project app_bundle_id: "bundle.id.app.new2" # Bundle ID to be set product_name: "Mobile App Name2" # App Name to be set + env_config: 'dev' # env name for this configuration. possible values: prod/dev/stage (values which config_settings.yaml defines) ``` ### Assets The config `whitelabel.yaml` can contain a few Asset items (every added Xcode project can have its own Assets). diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 5ab7602d4..24d602f45 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -48,6 +48,8 @@ 07D5DA3528D075AA00752FD9 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07D5DA3428D075AA00752FD9 /* AppDelegate.swift */; }; 07D5DA3E28D075AB00752FD9 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 07D5DA3D28D075AB00752FD9 /* Assets.xcassets */; }; 149FF39E2B9F1AB50034B33F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 149FF39C2B9F1AB50034B33F /* LaunchScreen.storyboard */; }; + 14D912D32C25483F0077CCCE /* FullStory in Frameworks */ = {isa = PBXBuildFile; productRef = 14D912D22C25483F0077CCCE /* FullStory */; }; + 14D912D72C2551F60077CCCE /* FullStoryAnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D912D62C2551F60077CCCE /* FullStoryAnalyticsService.swift */; }; 1924EDE8164C7AB17AD4946B /* Pods_App_OpenEdX.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FC49621A0942E5EE74BDC895 /* 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 */; }; @@ -133,6 +135,7 @@ 07D5DA3428D075AA00752FD9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 07D5DA3D28D075AB00752FD9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 149FF39D2B9F1AB50034B33F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; + 14D912D62C2551F60077CCCE /* FullStoryAnalyticsService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FullStoryAnalyticsService.swift; sourceTree = ""; }; 1BB1F1D0FABF8788646FBAF2 /* 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 = ""; }; 37C50995093E34142FDE0ED9 /* 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 = ""; }; A500668A2B613ED10024680B /* PushNotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationsManager.swift; sourceTree = ""; }; @@ -175,6 +178,7 @@ 027DB33028D8A063002B6862 /* Dashboard.framework in Frameworks */, A5BD3E302B83B0F7006A8983 /* SegmentFirebase in Frameworks */, A51CDBED2B6D2BEE009B6D4E /* SegmentBraze in Frameworks */, + 14D912D32C25483F0077CCCE /* FullStory in Frameworks */, A51CDBEF2B6D2BEE009B6D4E /* SegmentBrazeUI in Frameworks */, 1924EDE8164C7AB17AD4946B /* Pods_App_OpenEdX.framework in Frameworks */, ); @@ -263,6 +267,14 @@ path = OpenEdX; sourceTree = ""; }; + 14D912D52C2551CE0077CCCE /* FullStoryAnalyticsService */ = { + isa = PBXGroup; + children = ( + 14D912D62C2551F60077CCCE /* FullStoryAnalyticsService.swift */, + ); + path = FullStoryAnalyticsService; + sourceTree = ""; + }; 4E6FB43543890E90BB88D64D /* Frameworks */ = { isa = PBXGroup; children = ( @@ -315,6 +327,7 @@ A50066892B613E990024680B /* AnalyticsManager */, A59702272B83C84800CA064C /* FirebaseAnalyticsService */, A5C10D8D2B861A56008E864D /* SegmentAnalyticsService */, + 14D912D52C2551CE0077CCCE /* FullStoryAnalyticsService */, ); path = Managers; sourceTree = ""; @@ -413,6 +426,7 @@ 0770DE1528D07845006D8A5D /* Embed Frameworks */, DB97C0542B002EF00035C36F /* Process Config */, 02F175442A4E3B320019CD70 /* FirebaseCrashlytics */, + 14D912D42C25493C0077CCCE /* Run FullStory Asset Uploader */, ); buildRules = ( ); @@ -427,6 +441,7 @@ A5462D9E2B865713003B96A5 /* Segment */, 0780ABE22BFBA2E40093A4A6 /* FirebaseAnalytics */, 0780ABE42BFBA2E40093A4A6 /* FirebaseMessaging */, + 14D912D22C25483F0077CCCE /* FullStory */, ); productName = OpenEdX; productReference = 07D5DA3128D075AA00752FD9 /* OpenEdX.app */; @@ -463,6 +478,7 @@ A5BD3E2E2B83B0F7006A8983 /* XCRemoteSwiftPackageReference "analytics-swift-firebase" */, A5462D9D2B865713003B96A5 /* XCRemoteSwiftPackageReference "analytics-swift" */, 0780ABE12BFBA2E40093A4A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, + 14D912D12C25483F0077CCCE /* XCRemoteSwiftPackageReference "fullstory-swift-package-ios" */, ); productRefGroup = 07D5DA3228D075AA00752FD9 /* Products */; projectDirPath = ""; @@ -530,6 +546,24 @@ shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/SwiftLint/swiftlint\"\n"; }; + 14D912D42C25493C0077CCCE /* Run FullStory Asset Uploader */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Run FullStory Asset Uploader"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "if [\"$ FULLSTORY_ENABLED\" = \"YES\"]; then\n \"${PODS_ROOT}/FullStory/tools/FullStoryCommandLine\" \"${CONFIGURATION_BUILD_DIR}/${WRAPPER_NAME}\"\nfi\n"; + }; B9442FD26CE9A85A43FC2CFA /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -600,6 +634,7 @@ 024E69202AEFC3FB00FA0B59 /* MainScreenViewModel.swift in Sources */, 065275372BB1B4070093BCCA /* PipManager.swift in Sources */, 0770DE2028D0858A006D8A5D /* Router.swift in Sources */, + 14D912D72C2551F60077CCCE /* FullStoryAnalyticsService.swift in Sources */, 0293A2092A6FCDE50090A336 /* DashboardPersistence.swift in Sources */, 0770DE1728D080A1006D8A5D /* RouteController.swift in Sources */, A59568952B61630500ED4F90 /* DeepLinkManager.swift in Sources */, @@ -722,6 +757,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; + FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; @@ -810,6 +846,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; + FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; @@ -904,6 +941,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; + FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; @@ -992,6 +1030,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; + FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; @@ -1140,6 +1179,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; + FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; @@ -1174,6 +1214,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; + FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; @@ -1236,6 +1277,14 @@ requirement = { kind = upToNextMajorVersion; minimumVersion = 10.26.0; + }; + }; + 14D912D12C25483F0077CCCE /* XCRemoteSwiftPackageReference "fullstory-swift-package-ios" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/fullstorydev/fullstory-swift-package-ios"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.49.0; }; }; A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */ = { @@ -1282,6 +1331,11 @@ isa = XCSwiftPackageProductDependency; package = 0780ABE12BFBA2E40093A4A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseMessaging; + }; + 14D912D22C25483F0077CCCE /* FullStory */ = { + isa = XCSwiftPackageProductDependency; + package = 14D912D12C25483F0077CCCE /* XCRemoteSwiftPackageReference "fullstory-swift-package-ios" */; + productName = FullStory; }; A51CDBEC2B6D2BEE009B6D4E /* SegmentBraze */ = { isa = XCSwiftPackageProductDependency; diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index cabc57d33..a39285e7e 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -203,6 +203,10 @@ class AppAssembly: Assembly { FirebaseAnalyticsService() }.inObjectScope(.container) + container.register(FullStoryAnalyticsService.self) { r in + FullStoryAnalyticsService() + }.inObjectScope(.container) + container.register(PipManagerProtocol.self) { r in let config = r.resolve(ConfigProtocol.self)! return PipManager( diff --git a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift index 8208d1047..404ffed0e 100644 --- a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift +++ b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift @@ -19,6 +19,7 @@ import Swinject protocol AnalyticsService { func identify(id: String, username: String?, email: String?) func logEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) + func logScreenEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) } // swiftlint:disable type_body_length file_length @@ -52,6 +53,12 @@ class AnalyticsManager: AuthorizationAnalytics, let segmentService = Container.shared.resolve(SegmentAnalyticsService.self) { analyticsServices.append(segmentService) } + + if config.fullStory.enabled, + let fullStoryService = Container.shared.resolve(FullStoryAnalyticsService.self) { + analyticsServices.append(fullStoryService) + } + return analyticsServices } @@ -67,6 +74,12 @@ class AnalyticsManager: AuthorizationAnalytics, } } + private func logScreenEvent(_ event: AnalyticsEvent, parameters: [String: Any]? = nil) { + for service in services { + service.logScreenEvent(event, parameters: parameters) + } + } + // MARK: Generic event tracker functions public func trackEvent(_ event: AnalyticsEvent, parameters: [String: Any]? = nil) { logEvent(event, parameters: parameters) @@ -86,6 +99,24 @@ class AnalyticsManager: AuthorizationAnalytics, logEvent(event, parameters: [EventParamKey.name: biValue.rawValue]) } + public func trackScreenEvent(_ event: AnalyticsEvent, parameters: [String: Any]? = nil) { + logScreenEvent(event, parameters: parameters) + } + + public func trackScreenEvent(_ 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 }) + } + + logScreenEvent(event, parameters: eventParams) + } + + private func trackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + logScreenEvent(event, parameters: [EventParamKey.name: biValue.rawValue]) + } + // MARK: Pre Login public func userLogin(method: AuthMethod) { @@ -132,10 +163,14 @@ class AnalyticsManager: AuthorizationAnalytics, ) } + public func authTrackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + trackScreenEvent(event, biValue: biValue) + } + // MARK: MainScreenAnalytics public func mainDiscoveryTabClicked() { - trackEvent(.mainDiscoveryTabClicked, biValue: .mainDiscoveryTabClicked) + trackScreenEvent(.mainDiscoveryTabClicked, biValue: .mainDiscoveryTabClicked) } public func mainDashboardTabClicked() { @@ -143,11 +178,11 @@ class AnalyticsManager: AuthorizationAnalytics, } public func mainProgramsTabClicked() { - trackEvent(.mainProgramsTabClicked, biValue: .mainProgramsTabClicked) + trackScreenEvent(.mainProgramsTabClicked, biValue: .mainProgramsTabClicked) } public func mainProfileTabClicked() { - trackEvent(.mainProfileTabClicked, biValue: .mainProfileTabClicked) + trackScreenEvent(.mainProfileTabClicked, biValue: .mainProfileTabClicked) } // MARK: Discovery @@ -180,7 +215,7 @@ class AnalyticsManager: AuthorizationAnalytics, EventParamKey.courseName: courseName, EventParamKey.name: EventBIValue.dashboardCourseClicked.rawValue ] - logEvent(.dashboardCourseClicked, parameters: parameters) + logScreenEvent(.dashboardCourseClicked, parameters: parameters) } // MARK: Profile @@ -271,7 +306,7 @@ class AnalyticsManager: AuthorizationAnalytics, logEvent(event, parameters: parameters) } - public func profileEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + public func profileTrackEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { let parameters = [ EventParamKey.category: EventCategory.profile, EventParamKey.name: biValue.rawValue @@ -280,6 +315,15 @@ class AnalyticsManager: AuthorizationAnalytics, logEvent(event, parameters: parameters) } + public func profileScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + let parameters = [ + EventParamKey.category: EventCategory.profile, + EventParamKey.name: biValue.rawValue + ] + + logScreenEvent(event, parameters: parameters) + } + public func privacyPolicyClicked() { let parameters = [ EventParamKey.name: EventBIValue.privacyPolicyClicked.rawValue, @@ -380,13 +424,13 @@ class AnalyticsManager: AuthorizationAnalytics, logEvent(.externalLinkOpenAlertAction, parameters: parameters) } - public func discoveryEvent(event: AnalyticsEvent, biValue: EventBIValue) { + public func discoveryScreenEvent(event: AnalyticsEvent, biValue: EventBIValue) { let parameters = [ EventParamKey.category: EventCategory.discovery, EventParamKey.name: biValue.rawValue ] - logEvent(event, parameters: parameters) + logScreenEvent(event, parameters: parameters) } public func viewCourseClicked(courseId: String, courseName: String) { @@ -395,7 +439,7 @@ class AnalyticsManager: AuthorizationAnalytics, EventParamKey.courseName: courseName, EventParamKey.category: EventCategory.discovery ] - logEvent(.viewCourseClicked, parameters: parameters) + logScreenEvent(.viewCourseClicked, parameters: parameters) } public func resumeCourseClicked(courseId: String, courseName: String, blockId: String) { @@ -493,7 +537,7 @@ class AnalyticsManager: AuthorizationAnalytics, EventParamKey.courseName: courseName, EventParamKey.name: EventBIValue.courseOutlineCourseTabClicked.rawValue ] - logEvent(.courseOutlineCourseTabClicked, parameters: parameters) + logScreenEvent(.courseOutlineCourseTabClicked, parameters: parameters) } public func courseOutlineVideosTabClicked(courseId: String, courseName: String) { @@ -502,7 +546,7 @@ class AnalyticsManager: AuthorizationAnalytics, EventParamKey.courseName: courseName, EventParamKey.name: EventBIValue.courseOutlineVideosTabClicked.rawValue ] - logEvent(.courseOutlineVideosTabClicked, parameters: parameters) + logScreenEvent(.courseOutlineVideosTabClicked, parameters: parameters) } public func courseOutlineDatesTabClicked(courseId: String, courseName: String) { @@ -511,7 +555,7 @@ class AnalyticsManager: AuthorizationAnalytics, EventParamKey.courseName: courseName, EventParamKey.name: EventBIValue.courseOutlineDatesTabClicked.rawValue ] - logEvent(.courseOutlineDatesTabClicked, parameters: parameters) + logScreenEvent(.courseOutlineDatesTabClicked, parameters: parameters) } public func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) { @@ -520,7 +564,7 @@ class AnalyticsManager: AuthorizationAnalytics, EventParamKey.courseName: courseName, EventParamKey.name: EventBIValue.courseOutlineDiscussionTabClicked.rawValue ] - logEvent(.courseOutlineDiscussionTabClicked, parameters: parameters) + logScreenEvent(.courseOutlineDiscussionTabClicked, parameters: parameters) } public func courseOutlineHandoutsTabClicked(courseId: String, courseName: String) { @@ -529,7 +573,7 @@ class AnalyticsManager: AuthorizationAnalytics, EventParamKey.courseName: courseName, EventParamKey.name: EventBIValue.courseOutlineHandoutsTabClicked.rawValue ] - logEvent(.courseOutlineHandoutsTabClicked, parameters: parameters) + logScreenEvent(.courseOutlineHandoutsTabClicked, parameters: parameters) } func datesComponentTapped( @@ -616,6 +660,16 @@ class AnalyticsManager: AuthorizationAnalytics, logEvent(event, parameters: parameters) } + public func trackCourseScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue, courseID: String) { + let parameters = [ + EventParamKey.courseID: courseID, + EventParamKey.category: EventCategory.course, + EventParamKey.name: biValue.rawValue + ] + + logScreenEvent(event, parameters: parameters) + } + public func plsEvent( _ event: AnalyticsEvent, bivalue: EventBIValue, diff --git a/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift b/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift index fe49bc007..396af313f 100644 --- a/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift +++ b/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift @@ -26,6 +26,10 @@ class FirebaseAnalyticsService: AnalyticsService { Analytics.logEvent(name, parameters: formatParamaters(params: parameters)) } + + func logScreenEvent(_ event: Core.AnalyticsEvent, parameters: [String: Any]?) { + logEvent(event, parameters: parameters) + } } extension FirebaseAnalyticsService { diff --git a/OpenEdX/Managers/FullStoryAnalyticsService/FullStoryAnalyticsService.swift b/OpenEdX/Managers/FullStoryAnalyticsService/FullStoryAnalyticsService.swift new file mode 100644 index 000000000..69b594cd7 --- /dev/null +++ b/OpenEdX/Managers/FullStoryAnalyticsService/FullStoryAnalyticsService.swift @@ -0,0 +1,25 @@ +// +// FullStoryAnalyticsService.swift +// OpenEdX +// +// Created by Saeed Bashir on 4/17/24. +// + +import Foundation +import Core +import FullStory + +class FullStoryAnalyticsService: AnalyticsService { + + func identify(id: String, username: String?, email: String?) { + FS.identify(id, userVars: ["displayName": id]) + } + + func logEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + FS.event(event.rawValue, properties: parameters ?? [:]) + } + + func logScreenEvent(_ event: Core.AnalyticsEvent, parameters: [String: Any]?) { + FS.page(withName: event.rawValue, properties: parameters).start() + } +} diff --git a/OpenEdX/Managers/SegmentAnalyticsService/SegmentAnalyticsService.swift b/OpenEdX/Managers/SegmentAnalyticsService/SegmentAnalyticsService.swift index b8c2cd422..fad86a131 100644 --- a/OpenEdX/Managers/SegmentAnalyticsService/SegmentAnalyticsService.swift +++ b/OpenEdX/Managers/SegmentAnalyticsService/SegmentAnalyticsService.swift @@ -41,4 +41,8 @@ class SegmentAnalyticsService: AnalyticsService { properties: parameters ) } + + func logScreenEvent(_ event: Core.AnalyticsEvent, parameters: [String: Any]?) { + analytics?.screen(title: event.rawValue, properties: parameters) + } } diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift index 2ec255976..cf432b238 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift @@ -236,6 +236,9 @@ public struct EditProfileView: View { Theme.Colors.background .ignoresSafeArea() ) + .onFirstAppear { + viewModel.trackScreenEvent() + } } } } diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift b/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift index 4635416af..c877a6ecd 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileViewModel.swift @@ -367,5 +367,9 @@ public class EditProfileViewModel: ObservableObject { func trackProfileEditDoneClicked() { analytics.profileEditDoneClicked() } + + func trackScreenEvent() { + analytics.profileScreenEvent(.profileEdit, biValue: .profileEdit) + } } // swiftlint:enable type_body_length diff --git a/Profile/Profile/Presentation/ProfileAnalytics.swift b/Profile/Profile/Presentation/ProfileAnalytics.swift index 3713c15ba..6af217a04 100644 --- a/Profile/Profile/Presentation/ProfileAnalytics.swift +++ b/Profile/Profile/Presentation/ProfileAnalytics.swift @@ -25,7 +25,8 @@ public protocol ProfileAnalytics { func profileWifiToggle(action: String) func profileUserDeleteAccountClicked() func profileDeleteAccountSuccess(success: Bool) - func profileEvent(_ event: AnalyticsEvent, biValue: EventBIValue) + func profileTrackEvent(_ event: AnalyticsEvent, biValue: EventBIValue) + func profileScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue) } #if DEBUG @@ -45,6 +46,7 @@ class ProfileAnalyticsMock: ProfileAnalytics { public func profileWifiToggle(action: String) {} public func profileUserDeleteAccountClicked() {} public func profileDeleteAccountSuccess(success: Bool) {} - public func profileEvent(_ event: AnalyticsEvent, biValue: EventBIValue) {} + public func profileTrackEvent(_ event: AnalyticsEvent, biValue: EventBIValue) {} + public func profileScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue) {} } #endif diff --git a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift index ce6c4477b..1fe8ada15 100644 --- a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift +++ b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift @@ -179,7 +179,7 @@ public class SettingsViewModel: ObservableObject { } func trackLogoutClickedClicked() { - analytics.profileEvent(.userLogoutClicked, biValue: .userLogoutClicked) + analytics.profileTrackEvent(.userLogoutClicked, biValue: .userLogoutClicked) } } diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 35a5cae13..1817697a4 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -1171,6 +1171,18 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { perform?(`event`, `biValue`, `parameters`) } + open func trackScreenEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + addInvocation(.m_trackScreenEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackScreenEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, [String: Any]?) -> Void + perform?(`event`, `parameters`) + } + + open func trackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { + addInvocation(.m_trackScreenEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackScreenEvent__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 @@ -1195,14 +1207,30 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { perform?(`event`, `biValue`) } + open func trackScreenEvent(_ event: AnalyticsEvent) { + addInvocation(.m_trackScreenEvent__event(Parameter.value(`event`))) + let perform = methodPerformValue(.m_trackScreenEvent__event(Parameter.value(`event`))) as? (AnalyticsEvent) -> Void + perform?(`event`) + } + + open func trackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_trackScreenEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_trackScreenEvent__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_trackScreenEvent__eventparameters_parameters(Parameter, Parameter<[String: Any]?>) + case m_trackScreenEvent__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) + case m_trackScreenEvent__event(Parameter) + case m_trackScreenEvent__eventbiValue_biValue(Parameter, Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { @@ -1219,6 +1247,19 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) return Matcher.ComparisonResult(results) + case (.m_trackScreenEvent__eventparameters_parameters(let lhsEvent, let lhsParameters), .m_trackScreenEvent__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_trackScreenEvent__eventbiValue_biValueparameters_parameters(let lhsEvent, let lhsBivalue, let lhsParameters), .m_trackScreenEvent__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")) @@ -1245,6 +1286,17 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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) + + case (.m_trackScreenEvent__event(let lhsEvent), .m_trackScreenEvent__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_trackScreenEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_trackScreenEvent__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 } } @@ -1253,20 +1305,28 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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_trackScreenEvent__eventparameters_parameters(p0, p1): return p0.intValue + p1.intValue + case let .m_trackScreenEvent__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 + case let .m_trackScreenEvent__event(p0): return p0.intValue + case let .m_trackScreenEvent__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_trackScreenEvent__eventparameters_parameters: return ".trackScreenEvent(_:parameters:)" + case .m_trackScreenEvent__eventbiValue_biValueparameters_parameters: return ".trackScreenEvent(_: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:)" + case .m_trackScreenEvent__event: return ".trackScreenEvent(_:)" + case .m_trackScreenEvent__eventbiValue_biValue: return ".trackScreenEvent(_:biValue:)" } } } @@ -1287,10 +1347,14 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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 trackScreenEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackScreenEvent__eventparameters_parameters(`event`, `parameters`))} + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackScreenEvent__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 static func trackScreenEvent(_ event: Parameter) -> Verify { return Verify(method: .m_trackScreenEvent__event(`event`))} + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_trackScreenEvent__eventbiValue_biValue(`event`, `biValue`))} } public struct Perform { @@ -1303,6 +1367,12 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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 trackScreenEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__eventparameters_parameters(`event`, `parameters`), performs: perform) + } + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__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) } @@ -1315,6 +1385,12 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { 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 static func trackScreenEvent(_ event: Parameter, perform: @escaping (AnalyticsEvent) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__event(`event`), performs: perform) + } + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } } public func given(_ method: Given) { @@ -2153,9 +2229,15 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { 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 + open func profileTrackEvent(_ 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) + } + + open func profileScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_profileScreenEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_profileScreenEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void perform?(`event`, `biValue`) } @@ -2177,6 +2259,7 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { case m_profileUserDeleteAccountClicked case m_profileDeleteAccountSuccess__success_success(Parameter) case m_profileEvent__eventbiValue_biValue(Parameter, Parameter) + case m_profileScreenEvent__eventbiValue_biValue(Parameter, Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { @@ -2227,6 +2310,12 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { 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) + + case (.m_profileScreenEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_profileScreenEvent__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 } } @@ -2249,6 +2338,7 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { 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 + case let .m_profileScreenEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue } } func assertionName() -> String { @@ -2269,6 +2359,7 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { case .m_profileUserDeleteAccountClicked: return ".profileUserDeleteAccountClicked()" case .m_profileDeleteAccountSuccess__success_success: return ".profileDeleteAccountSuccess(success:)" case .m_profileEvent__eventbiValue_biValue: return ".profileEvent(_:biValue:)" + case .m_profileScreenEvent__eventbiValue_biValue: return ".profileScreenEvent(_:biValue:)" } } } @@ -2303,6 +2394,7 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { 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 static func profileScreenEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_profileScreenEvent__eventbiValue_biValue(`event`, `biValue`))} } public struct Perform { @@ -2357,6 +2449,9 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { 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 static func profileScreenEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_profileScreenEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } } public func given(_ method: Given) { diff --git a/config_script/process_config.py b/config_script/process_config.py index 1fdea0270..c03e3d8f4 100644 --- a/config_script/process_config.py +++ b/config_script/process_config.py @@ -247,6 +247,14 @@ def add_microsoft_config(self, config, plist): scheme = ["msauth." + bundle_identifier] self.add_url_scheme(scheme, plist, False) self.add_application_query_schemes(["msauthv2", "msauthv3"], plist) + + def add_fullstory_config(self, config, plist): + fullstory = config.get('FULLSTORY', {}) + enabled = fullstory.get('ENABLED') + orgID = fullstory.get('ORG_ID') + + if enabled and orgID: + plist["FullStory"] = {"orgID": orgID} def update_info_plist(self, plist_data, plist_path): if not plist_path: @@ -303,6 +311,7 @@ def process_plist_files(configuration_manager, plist_manager, config): 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) + configuration_manager.add_fullstory_config(config, info_plist_content) configuration_manager.update_info_plist(info_plist_content, info_plist_path) diff --git a/config_script/whitelabel.py b/config_script/whitelabel.py index 00eab9f92..24d1b01a9 100644 --- a/config_script/whitelabel.py +++ b/config_script/whitelabel.py @@ -9,6 +9,7 @@ from PIL import Image import re from textwrap import dedent +from process_config import PlistManager # type: ignore class WhitelabelApp: EXAMPLE_CONFIG_FILE = dedent(""" @@ -46,9 +47,11 @@ class WhitelabelApp: config1: # build configuration name in project app_bundle_id: "bundle.id.app.new1" # bundle ID which should be set product_name: "Mobile App Name1" # app name which should be set + env_config: 'prod' # env name for this configuration. possible values: prod/dev/stage (values which config_settings.yaml defines) config2: # build configuration name in project app_bundle_id: "bundle.id.app.new2" # bundle ID which should be set product_name: "Mobile App Name2" # app name which should be set + env_config: 'dev' # env name for this configuration. possible values: prod/dev/stage (values which config_settings.yaml defines) font: font_import_file_path: 'path/to/importing/Font_file.ttf' # path to ttf font file what should be imported to project project_font_file_path: 'path/to/font/file/in/project/font.ttf' # path to existing ttf font file in project @@ -90,6 +93,7 @@ def whitelabel(self): self.copy_project_files() if self.project_config: self.set_app_project_config() + self.set_flags_from_mobile_config() def copy_assets(self): if self.assets: @@ -326,12 +330,12 @@ def replace_parameter_for_build_config(self, config_file_string, config_name, ne # replace existing parameter value with new value config_string_out = config_string.replace(parameter_string, new_param_string) else: - errors_texts.append("project_config->configurations->"+config_name+": Check regex please. Can't find place in project file where insert '"+new_param_string+"'") + errors_texts.append(config_name+": Check regex please. Can't find place in project file where place '"+new_param_string+"'") # if something found if config_string != config_string_out: config_file_string = config_file_string.replace(config_string, config_string_out) else: - errors_texts.append("project_config->configurations->"+config_name+": not found in project file") + errors_texts.append(config_name+": not found in project file") return config_file_string def regex_string_for_build_config(self, build_config): @@ -507,6 +511,96 @@ def copy_project_files(self): else: logging.debug("Project's Files for copying not found in config") + # params from MOBILE CONFIG + CONFIG_SETTINGS_YAML_FILENAME = 'config_settings.yaml' + DEFAULT_CONFIG_PATH = './default_config/' + CONFIG_SETTINGS_YAML_FILENAME + CONFIG_DIRECTORY_NAME = 'config_directory' + CONFIG_MAPPINGS = 'config_mapping' + MAPPINGS_FILENAME = 'file_mappings.yaml' + + def parse_yaml(self, file_path): + try: + with open(file_path, 'r') as file: + return yaml.safe_load(file) + except Exception as e: + logging.error(f"Unable to open or read the file '{file_path}': {e}") + return None + + def get_mobile_config(self, config_directory, config_folder, errors_texts): + # get path to mappings file + path = os.path.join(config_directory, config_folder) + mappings_path = os.path.join(path, self.MAPPINGS_FILENAME) + # read mappings file + data = self.parse_yaml(mappings_path) + if data: + # get config for ios described in mappings file + ios_files = data.get('ios', {}).get('files', []) + # re-use PlistManager class from process_config.py script + plist_manager = PlistManager(path, ios_files) + config = plist_manager.load_config() + if config: + return config + else: + errors_texts.append("Unable to parse config for "+config_folder) + else: + errors_texts.append("Files mappings for "+config_folder+" not found") + return None + + def set_flags_from_mobile_config(self): + # get path to mobile config + config_settings = self.parse_yaml(self.CONFIG_SETTINGS_YAML_FILENAME) + if not config_settings: + config_settings = self.parse_yaml(self.DEFAULT_CONFIG_PATH) + config_directory = config_settings.get(self.CONFIG_DIRECTORY_NAME) + # check if we found config directory + if config_directory: + # check if configurations exist + if "configurations" in self.project_config: + configurations = self.project_config["configurations"] + # read project file + with open(self.config_project_path, 'r') as openfile: + project_file_string = openfile.read() + errors_texts = [] + # iterate for all configurations + for name, config in configurations.items(): + if 'env_config' in config: + # get folder name for mobile config for current configuration by env_config + config_folder = config_settings.get(self.CONFIG_MAPPINGS, {}).get(config['env_config']) + if config_folder: + # replace fullstory flag + project_file_string = self.replace_fullstory_flag(project_file_string, config_directory, name, config_folder, errors_texts) + else: + logging.error("Config folder for '"+config['env_config']+"' is not defined in config_settings.yaml->config_mapping") + else: + logging.error("'env_config' is not defined for "+name) + # write to project file + with open(self.config_project_path, 'w') as openfile: + openfile.write(project_file_string) + # print success message or errors if are presented + if len(errors_texts) == 0: + logging.debug("Mobile config user-defined flags were successfully changed") + else: + for error in errors_texts: + logging.error(error) + else: + logging.error("Project configurations are not defined") + else: + logging.error("Mobile config directory not found") + + def replace_fullstory_flag(self, project_file_string, config_directory, config_name, config_folder, errors_texts): + # get mobile config + mobile_config = self.get_mobile_config(config_directory, config_folder, errors_texts) + if mobile_config: + # get FULLSTORY settings from mobile config + fullstory_config = mobile_config.get('FULLSTORY', {}) + if fullstory_config: + fullstory_config_enabled = fullstory_config.get('ENABLED') + fullstory_string = "FULLSTORY_ENABLED = YES;" if fullstory_config_enabled else "FULLSTORY_ENABLED = NO;" + fullstory_regex = "FULLSTORY_ENABLED = .*;" + # serach by regex and replace + project_file_string = self.replace_parameter_for_build_config(project_file_string, config_name, fullstory_string, fullstory_regex, errors_texts) + return project_file_string + def main(): """ Parse the command line arguments, and pass them to WhitelabelApp. From bd270b7be92a31cfa9c07f7e50c9f4c06161075e Mon Sep 17 00:00:00 2001 From: Vadim Kuznetsov Date: Tue, 9 Jul 2024 09:49:23 +0300 Subject: [PATCH 26/55] Fix: CoreData crash (#480) * fix: crash with CoreData * chore: moved to coreData to background queue * chore: moved core data work to background * chore: review's changes --- .../Persistence/CorePersistenceProtocol.swift | 11 +- Core/Core/Network/DownloadManager.swift | 107 ++++---- Course/Course/Data/CourseRepository.swift | 8 +- .../CoursePersistenceProtocol.swift | 6 +- Course/Course/Domain/CourseInteractor.swift | 2 +- .../Container/CourseContainerViewModel.swift | 10 +- .../CourseVerticalViewModel.swift | 2 +- .../Dashboard/Data/DashboardRepository.swift | 8 +- .../DashboardPersistenceProtocol.swift | 4 +- .../Domain/DashboardInteractor.swift | 6 +- .../Presentation/ListDashboardViewModel.swift | 2 +- .../Discovery/Data/DiscoveryRepository.swift | 8 +- .../DiscoveryPersistenceProtocol.swift | 4 +- .../Domain/DiscoveryInteractor.swift | 6 +- .../NativeDiscovery/DiscoveryViewModel.swift | 2 +- OpenEdX/Data/CorePersistence.swift | 199 +++++++-------- OpenEdX/Data/CoursePersistence.swift | 228 +++++++++--------- OpenEdX/Data/DashboardPersistence.swift | 215 +++++++++-------- OpenEdX/Data/DiscoveryPersistence.swift | 84 ++++--- 19 files changed, 460 insertions(+), 452 deletions(-) diff --git a/Core/Core/Data/Persistence/CorePersistenceProtocol.swift b/Core/Core/Data/Persistence/CorePersistenceProtocol.swift index 3a6ec2acf..49d69b1c3 100644 --- a/Core/Core/Data/Persistence/CorePersistenceProtocol.swift +++ b/Core/Core/Data/Persistence/CorePersistenceProtocol.swift @@ -12,15 +12,14 @@ public protocol CorePersistenceProtocol { func set(userId: Int) func getUserID() -> Int? func publisher() -> AnyPublisher - func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) - func nextBlockForDownloading() -> DownloadDataTask? + func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) async + func nextBlockForDownloading() async -> DownloadDataTask? func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) - func deleteDownloadDataTask(id: String) throws + func deleteDownloadDataTask(id: String) async throws 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) - func getDownloadDataTasksForCourse(_ courseId: String, completion: @escaping ([DownloadDataTask]) -> Void) + func getDownloadDataTasks() async -> [DownloadDataTask] + func getDownloadDataTasksForCourse(_ courseId: String) async -> [DownloadDataTask] } public final class CoreBundle { diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index 1db912da9..bc3701695 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -106,7 +106,7 @@ public protocol DownloadManagerProtocol { func publisher() -> AnyPublisher func eventPublisher() -> AnyPublisher - func addToDownloadQueue(blocks: [CourseBlock]) throws + func addToDownloadQueue(blocks: [CourseBlock]) async throws func getDownloadTasks() async -> [DownloadDataTask] func getDownloadTasksForCourse(_ courseId: String) async -> [DownloadDataTask] @@ -119,10 +119,9 @@ public protocol DownloadManagerProtocol { func deleteFile(blocks: [CourseBlock]) async func deleteAllFiles() async - func fileUrl(for blockId: String) async -> URL? - - func resumeDownloading() throws func fileUrl(for blockId: String) -> URL? + + func resumeDownloading() async throws func isLargeVideosSize(blocks: [CourseBlock]) -> Bool func removeAppSupportDirectoryUnusedContent() @@ -142,7 +141,6 @@ public enum DownloadManagerEvent { } public class DownloadManager: DownloadManagerProtocol { - // MARK: - Properties public var currentDownloadTask: DownloadDataTask? @@ -173,7 +171,9 @@ public class DownloadManager: DownloadManagerProtocol { self.appStorage = appStorage self.connectivity = connectivity self.backgroundTask() - try? self.resumeDownloading() + Task { + try? await self.resumeDownloading() + } } // MARK: - Publishers @@ -197,37 +197,29 @@ public class DownloadManager: DownloadManagerProtocol { } public func getDownloadTasks() async -> [DownloadDataTask] { - await withCheckedContinuation { continuation in - persistence.getDownloadDataTasks { downloads in - continuation.resume(returning: downloads) - } - } + await persistence.getDownloadDataTasks() } public func getDownloadTasksForCourse(_ courseId: String) async -> [DownloadDataTask] { - await withCheckedContinuation { continuation in - persistence.getDownloadDataTasksForCourse(courseId) { downloads in - continuation.resume(returning: downloads) - } - } + await persistence.getDownloadDataTasksForCourse(courseId) } - public func addToDownloadQueue(blocks: [CourseBlock]) throws { + public func addToDownloadQueue(blocks: [CourseBlock]) async throws { if userCanDownload() { - persistence.addToDownloadQueue( + await persistence.addToDownloadQueue( blocks: blocks, downloadQuality: downloadQuality ) currentDownloadEventPublisher.send(.added) guard !isDownloadingInProgress else { return } - try newDownload() + try await newDownload() } else { throw NoWiFiError() } } - public func resumeDownloading() throws { - try newDownload() + public func resumeDownloading() async throws { + try await newDownload() } public func cancelDownloading(courseId: String, blocks: [CourseBlock]) async throws { @@ -240,13 +232,13 @@ public class DownloadManager: DownloadManagerProtocol { downloaded.forEach { currentDownloadEventPublisher.send(.canceled($0)) } - try newDownload() + try await newDownload() } public func cancelDownloading(task: DownloadDataTask) async throws { downloadRequest?.cancel() do { - try persistence.deleteDownloadDataTask(id: task.id) + try await persistence.deleteDownloadDataTask(id: task.id) if let fileUrl = await fileUrl(for: task.id) { try FileManager.default.removeItem(at: fileUrl) } @@ -254,7 +246,7 @@ public class DownloadManager: DownloadManagerProtocol { } catch { NSLog("Error deleting file: \(error.localizedDescription)") } - try newDownload() + try await newDownload() } public func cancelDownloading(courseId: String) async throws { @@ -262,7 +254,7 @@ public class DownloadManager: DownloadManagerProtocol { await cancel(tasks: tasks) currentDownloadEventPublisher.send(.courseCanceled(courseId)) downloadRequest?.cancel() - try newDownload() + try await newDownload() } public func cancelAllDownloading() async throws { @@ -270,7 +262,7 @@ public class DownloadManager: DownloadManagerProtocol { await cancel(tasks: tasks) currentDownloadEventPublisher.send(.allCanceled) downloadRequest?.cancel() - try newDownload() + try await newDownload() } public func deleteFile(blocks: [CourseBlock]) async { @@ -279,7 +271,7 @@ public class DownloadManager: DownloadManagerProtocol { if let fileURL = await fileUrl(for: block.id) { try FileManager.default.removeItem(at: fileURL) } - try persistence.deleteDownloadDataTask(id: block.id) + try await persistence.deleteDownloadDataTask(id: block.id) currentDownloadEventPublisher.send(.deletedFile(block.id)) } catch { debugLog("Error deleting file: \(error.localizedDescription)") @@ -301,24 +293,13 @@ public class DownloadManager: DownloadManagerProtocol { currentDownloadEventPublisher.send(.clearedAll) } - public func fileUrl(for blockId: String) async -> URL? { - await withCheckedContinuation { continuation in - persistence.downloadDataTask(for: blockId) { [weak self] data in - guard let data = data, data.url.count > 0, data.state == .finished else { - continuation.resume(returning: nil) - return - } - let path = self?.videosFolderUrl - let fileName = data.fileName - continuation.resume(returning: path?.appendingPathComponent(fileName)) - } - } - } - public func fileUrl(for blockId: String) -> URL? { guard let data = persistence.downloadDataTask(for: blockId), data.url.count > 0, - data.state == .finished else { return nil } + data.state == .finished + else { + return nil + } let path = videosFolderUrl let fileName = data.fileName return path?.appendingPathComponent(fileName) @@ -326,11 +307,11 @@ public class DownloadManager: DownloadManagerProtocol { // MARK: - Private Intents - private func newDownload() throws { + private func newDownload() async throws { guard userCanDownload() else { throw NoWiFiError() } - guard let downloadTask = persistence.nextBlockForDownloading() else { + guard let downloadTask = await persistence.nextBlockForDownloading() else { isDownloadingInProgress = false return } @@ -389,32 +370,30 @@ public class DownloadManager: DownloadManagerProtocol { ) self.currentDownloadTask?.state = .finished self.currentDownloadEventPublisher.send(.finished(download)) - try? self.newDownload() + Task { + try? await self.newDownload() + } } } } - private func waitingAll() { - persistence.getDownloadDataTasks { [weak self] tasks in - guard let self else { return } - Task { - for task in tasks.filter({ $0.state == .inProgress }) { - self.persistence.updateDownloadState( - id: task.id, - state: .waiting, - resumeData: nil - ) - self.currentDownloadEventPublisher.send(.added) - } - self.downloadRequest?.cancel() - } + private func waitingAll() async { + let tasks = await persistence.getDownloadDataTasks() + for task in tasks.filter({ $0.state == .inProgress }) { + self.persistence.updateDownloadState( + id: task.id, + state: .waiting, + resumeData: nil + ) + self.currentDownloadEventPublisher.send(.added) } + self.downloadRequest?.cancel() } private func cancel(tasks: [DownloadDataTask]) async { for task in tasks { do { - try persistence.deleteDownloadDataTask(id: task.id) + try await persistence.deleteDownloadDataTask(id: task.id) if let fileUrl = await fileUrl(for: task.id) { try FileManager.default.removeItem(at: fileUrl) } @@ -428,9 +407,11 @@ public class DownloadManager: DownloadManagerProtocol { backgroundTaskProvider.eventPublisher() .sink { [weak self] state in guard let self else { return } - switch state { - case.didBecomeActive: try? self.resumeDownloading() - case .didEnterBackground: self.waitingAll() + Task { + switch state { + case.didBecomeActive: try? await self.resumeDownloading() + case .didEnterBackground: await self.waitingAll() + } } } .store(in: &cancellables) diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index 612273fe5..e5d4106b5 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -10,7 +10,7 @@ import Core public protocol CourseRepositoryProtocol { func getCourseBlocks(courseID: String) async throws -> CourseStructure - func getLoadedCourseBlocks(courseID: String) throws -> CourseStructure + func getLoadedCourseBlocks(courseID: String) async throws -> CourseStructure func blockCompletionRequest(courseID: String, blockID: String) async throws func getHandouts(courseID: String) async throws -> String? func getUpdates(courseID: String) async throws -> [CourseUpdate] @@ -50,8 +50,8 @@ public class CourseRepository: CourseRepositoryProtocol { return parsedStructure } - public func getLoadedCourseBlocks(courseID: String) throws -> CourseStructure { - let localData = try persistence.loadCourseStructure(courseID: courseID) + public func getLoadedCourseBlocks(courseID: String) async throws -> CourseStructure { + let localData = try await persistence.loadCourseStructure(courseID: courseID) return parseCourseStructure(course: localData) } @@ -85,7 +85,7 @@ public class CourseRepository: CourseRepositoryProtocol { } public func getSubtitles(url: String, selectedLanguage: String) async throws -> String { - if let subtitlesOffline = persistence.loadSubtitles(url: url + selectedLanguage) { + if let subtitlesOffline = await persistence.loadSubtitles(url: url + selectedLanguage) { return subtitlesOffline } else { let result = try await api.requestData(CourseEndpoint.getSubtitles( diff --git a/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift b/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift index 35f8328c2..ff3080a19 100644 --- a/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift +++ b/Course/Course/Data/Persistence/CoursePersistenceProtocol.swift @@ -9,12 +9,12 @@ import CoreData import Core public protocol CoursePersistenceProtocol { - func loadEnrollments() throws -> [Core.CourseItem] + func loadEnrollments() async throws -> [Core.CourseItem] func saveEnrollments(items: [Core.CourseItem]) - func loadCourseStructure(courseID: String) throws -> DataLayer.CourseStructure + func loadCourseStructure(courseID: String) async throws -> DataLayer.CourseStructure func saveCourseStructure(structure: DataLayer.CourseStructure) func saveSubtitles(url: String, subtitlesString: String) - func loadSubtitles(url: String) -> String? + func loadSubtitles(url: String) async -> String? func saveCourseDates(courseID: String, courseDates: CourseDates) func loadCourseDates(courseID: String) throws -> CourseDates } diff --git a/Course/Course/Domain/CourseInteractor.swift b/Course/Course/Domain/CourseInteractor.swift index f76d5ed2e..dcd9eac1d 100644 --- a/Course/Course/Domain/CourseInteractor.swift +++ b/Course/Course/Domain/CourseInteractor.swift @@ -64,7 +64,7 @@ public class CourseInteractor: CourseInteractorProtocol { } public func getLoadedCourseBlocks(courseID: String) async throws -> CourseStructure { - return try repository.getLoadedCourseBlocks(courseID: courseID) + return try await repository.getLoadedCourseBlocks(courseID: courseID) } public func blockCompletionRequest(courseID: String, blockID: String) async throws { diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index 51e687f3e..8f95d25b9 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -346,12 +346,12 @@ public class CourseContainerViewModel: BaseCourseViewModel { return tasks } - func continueDownload() { + func continueDownload() async { guard let blocks = waitingDownloads else { return } do { - try manager.addToDownloadQueue(blocks: blocks) + try await manager.addToDownloadQueue(blocks: blocks) } catch let error { if error is NoWiFiError { errorMessage = CoreLocalization.Error.wifi @@ -481,7 +481,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { do { switch state { case .available: - try manager.addToDownloadQueue(blocks: blocks) + try await manager.addToDownloadQueue(blocks: blocks) case .downloading: try await manager.cancelDownloading(courseId: courseStructure?.id ?? "", blocks: blocks) case .finished: @@ -507,7 +507,9 @@ public class CourseContainerViewModel: BaseCourseViewModel { self.router.dismiss(animated: true) }, okTapped: { - self.continueDownload() + Task { + await self.continueDownload() + } self.router.dismiss(animated: true) }, type: .default(positiveAction: CourseLocalization.Alert.accept, image: nil) diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift index 4ce5ebd70..5247ca700 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift @@ -67,7 +67,7 @@ public class CourseVerticalViewModel: BaseCourseViewModel { do { switch state { case .available: - try manager.addToDownloadQueue(blocks: blocks) + try await manager.addToDownloadQueue(blocks: blocks) downloadState[vertical.id] = .downloading case .downloading: try await manager.cancelDownloading(courseId: vertical.courseId, blocks: blocks) diff --git a/Dashboard/Dashboard/Data/DashboardRepository.swift b/Dashboard/Dashboard/Data/DashboardRepository.swift index df8cea243..00fe98df7 100644 --- a/Dashboard/Dashboard/Data/DashboardRepository.swift +++ b/Dashboard/Dashboard/Data/DashboardRepository.swift @@ -10,7 +10,7 @@ import Core public protocol DashboardRepositoryProtocol { func getEnrollments(page: Int) async throws -> [CourseItem] - func getEnrollmentsOffline() throws -> [CourseItem] + func getEnrollmentsOffline() async throws -> [CourseItem] func getPrimaryEnrollment(pageSize: Int) async throws -> PrimaryEnrollment func getPrimaryEnrollmentOffline() async throws -> PrimaryEnrollment func getAllCourses(filteredBy: String, page: Int) async throws -> PrimaryEnrollment @@ -41,8 +41,8 @@ public class DashboardRepository: DashboardRepositoryProtocol { } - public func getEnrollmentsOffline() throws -> [CourseItem] { - return try persistence.loadEnrollments() + public func getEnrollmentsOffline() async throws -> [CourseItem] { + return try await persistence.loadEnrollments() } public func getPrimaryEnrollment(pageSize: Int) async throws -> PrimaryEnrollment { @@ -59,7 +59,7 @@ public class DashboardRepository: DashboardRepositoryProtocol { } public func getPrimaryEnrollmentOffline() async throws -> PrimaryEnrollment { - return try persistence.loadPrimaryEnrollment() + return try await persistence.loadPrimaryEnrollment() } public func getAllCourses(filteredBy: String, page: Int) async throws -> PrimaryEnrollment { diff --git a/Dashboard/Dashboard/Data/Persistence/DashboardPersistenceProtocol.swift b/Dashboard/Dashboard/Data/Persistence/DashboardPersistenceProtocol.swift index 3747d2c8e..2257c8238 100644 --- a/Dashboard/Dashboard/Data/Persistence/DashboardPersistenceProtocol.swift +++ b/Dashboard/Dashboard/Data/Persistence/DashboardPersistenceProtocol.swift @@ -9,9 +9,9 @@ import CoreData import Core public protocol DashboardPersistenceProtocol { - func loadEnrollments() throws -> [CourseItem] + func loadEnrollments() async throws -> [CourseItem] func saveEnrollments(items: [CourseItem]) - func loadPrimaryEnrollment() throws -> PrimaryEnrollment + func loadPrimaryEnrollment() async throws -> PrimaryEnrollment func savePrimaryEnrollment(enrollments: PrimaryEnrollment) } diff --git a/Dashboard/Dashboard/Domain/DashboardInteractor.swift b/Dashboard/Dashboard/Domain/DashboardInteractor.swift index 0d55f0a4e..60a920eaf 100644 --- a/Dashboard/Dashboard/Domain/DashboardInteractor.swift +++ b/Dashboard/Dashboard/Domain/DashboardInteractor.swift @@ -11,7 +11,7 @@ import Core //sourcery: AutoMockable public protocol DashboardInteractorProtocol { func getEnrollments(page: Int) async throws -> [CourseItem] - func getEnrollmentsOffline() throws -> [CourseItem] + func getEnrollmentsOffline() async throws -> [CourseItem] func getPrimaryEnrollment(pageSize: Int) async throws -> PrimaryEnrollment func getPrimaryEnrollmentOffline() async throws -> PrimaryEnrollment func getAllCourses(filteredBy: String, page: Int) async throws -> PrimaryEnrollment @@ -30,8 +30,8 @@ public class DashboardInteractor: DashboardInteractorProtocol { return try await repository.getEnrollments(page: page) } - public func getEnrollmentsOffline() throws -> [CourseItem] { - return try repository.getEnrollmentsOffline() + public func getEnrollmentsOffline() async throws -> [CourseItem] { + return try await repository.getEnrollmentsOffline() } public func getPrimaryEnrollment(pageSize: Int) async throws -> PrimaryEnrollment { diff --git a/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift b/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift index bef00add1..f962824e9 100644 --- a/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift +++ b/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift @@ -75,7 +75,7 @@ public class ListDashboardViewModel: ObservableObject { } fetchInProgress = false } else { - courses = try interactor.getEnrollmentsOffline() + courses = try await interactor.getEnrollmentsOffline() self.nextPage += 1 fetchInProgress = false } diff --git a/Discovery/Discovery/Data/DiscoveryRepository.swift b/Discovery/Discovery/Data/DiscoveryRepository.swift index 1d6f5e0a9..fd2121693 100644 --- a/Discovery/Discovery/Data/DiscoveryRepository.swift +++ b/Discovery/Discovery/Data/DiscoveryRepository.swift @@ -13,7 +13,7 @@ import Alamofire public protocol DiscoveryRepositoryProtocol { func getDiscovery(page: Int) async throws -> [CourseItem] func searchCourses(page: Int, searchTerm: String) async throws -> [CourseItem] - func getDiscoveryOffline() throws -> [CourseItem] + func getDiscoveryOffline() async throws -> [CourseItem] func getCourseDetails(courseID: String) async throws -> CourseDetails func getLoadedCourseDetails(courseID: String) async throws -> CourseDetails func enrollToCourse(courseID: String) async throws -> Bool @@ -44,8 +44,8 @@ public class DiscoveryRepository: DiscoveryRepositoryProtocol { return discoveryResponse } - public func getDiscoveryOffline() throws -> [CourseItem] { - return try persistence.loadDiscovery() + public func getDiscoveryOffline() async throws -> [CourseItem] { + try await persistence.loadDiscovery() } public func searchCourses(page: Int, searchTerm: String) async throws -> [CourseItem] { @@ -68,7 +68,7 @@ public class DiscoveryRepository: DiscoveryRepositoryProtocol { } public func getLoadedCourseDetails(courseID: String) async throws -> CourseDetails { - return try persistence.loadCourseDetails(courseID: courseID) + try await persistence.loadCourseDetails(courseID: courseID) } public func enrollToCourse(courseID: String) async throws -> Bool { diff --git a/Discovery/Discovery/Data/Persistence/DiscoveryPersistenceProtocol.swift b/Discovery/Discovery/Data/Persistence/DiscoveryPersistenceProtocol.swift index 1c8b3fd6c..0445a690c 100644 --- a/Discovery/Discovery/Data/Persistence/DiscoveryPersistenceProtocol.swift +++ b/Discovery/Discovery/Data/Persistence/DiscoveryPersistenceProtocol.swift @@ -9,9 +9,9 @@ import CoreData import Core public protocol DiscoveryPersistenceProtocol { - func loadDiscovery() throws -> [CourseItem] + func loadDiscovery() async throws -> [CourseItem] func saveDiscovery(items: [CourseItem]) - func loadCourseDetails(courseID: String) throws -> CourseDetails + func loadCourseDetails(courseID: String) async throws -> CourseDetails func saveCourseDetails(course: CourseDetails) } diff --git a/Discovery/Discovery/Domain/DiscoveryInteractor.swift b/Discovery/Discovery/Domain/DiscoveryInteractor.swift index a0bffe3ca..403463dc5 100644 --- a/Discovery/Discovery/Domain/DiscoveryInteractor.swift +++ b/Discovery/Discovery/Domain/DiscoveryInteractor.swift @@ -11,7 +11,7 @@ import Core //sourcery: AutoMockable public protocol DiscoveryInteractorProtocol { func discovery(page: Int) async throws -> [CourseItem] - func discoveryOffline() throws -> [CourseItem] + func discoveryOffline() async throws -> [CourseItem] func search(page: Int, searchTerm: String) async throws -> [CourseItem] func getLoadedCourseDetails(courseID: String) async throws -> CourseDetails func getCourseDetails(courseID: String) async throws -> CourseDetails @@ -34,8 +34,8 @@ public class DiscoveryInteractor: DiscoveryInteractorProtocol { return try await repository.searchCourses(page: page, searchTerm: searchTerm) } - public func discoveryOffline() throws -> [CourseItem] { - return try repository.getDiscoveryOffline() + public func discoveryOffline() async throws -> [CourseItem] { + try await repository.getDiscoveryOffline() } public func getCourseDetails(courseID: String) async throws -> CourseDetails { diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryViewModel.swift b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryViewModel.swift index bcb8f1022..8f64f458e 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryViewModel.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryViewModel.swift @@ -112,7 +112,7 @@ public class DiscoveryViewModel: ObservableObject { fetchInProgress = false } else { - courses = try interactor.discoveryOffline() + courses = try await interactor.discoveryOffline() self.nextPage += 1 fetchInProgress = false } diff --git a/OpenEdX/Data/CorePersistence.swift b/OpenEdX/Data/CorePersistence.swift index 8af067d07..f9282bd27 100644 --- a/OpenEdX/Data/CorePersistence.swift +++ b/OpenEdX/Data/CorePersistence.swift @@ -11,7 +11,30 @@ import CoreData import Combine public class CorePersistence: CorePersistenceProtocol { - + struct CorePersistenceHelper { + static func fetchCDDownloadData( + predicate: CDPredicate? = nil, + fetchLimit: Int? = nil, + context: NSManagedObjectContext, + userId: Int32? + ) 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 = userId else { + return true + } + debugLog(userId, "-userId-") + return $0.userId == userId + } + return data + } + } // MARK: - Predicate enum CDPredicate { @@ -53,27 +76,29 @@ public class CorePersistence: CorePersistenceProtocol { public func addToDownloadQueue( blocks: [CourseBlock], downloadQuality: DownloadQuality - ) { + ) async { + let userId = getUserId32() ?? 0 for block in blocks { 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 { + await context.perform {[context] in + let data = try? CorePersistenceHelper.fetchCDDownloadData( + predicate: CDPredicate.id(downloadDataId), + context: context, + userId: userId + ) + guard data?.first == nil else { return } + + guard let video = block.encodedVideo?.video(downloadQuality: downloadQuality), + let url = video.url, + let fileExtension = URL(string: url)?.pathExtension + else { return } + + let fileName = "\(block.id).\(fileExtension)" let newDownloadData = CDDownloadData(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump newDownloadData.id = downloadDataId newDownloadData.blockId = block.id - newDownloadData.userId = getUserId32() ?? 0 + newDownloadData.userId = userId newDownloadData.courseId = block.courseId newDownloadData.url = url newDownloadData.fileName = fileName @@ -87,85 +112,82 @@ public class CorePersistence: CorePersistenceProtocol { } } - public func getDownloadDataTasks(completion: @escaping ([DownloadDataTask]) -> Void) { - context.performAndWait { - guard let data = try? fetchCDDownloadData() else { - completion([]) - return + public func getDownloadDataTasks() async -> [DownloadDataTask] { + let userId = getUserId32() ?? 0 + return await context.perform {[context] in + guard let data = try? CorePersistenceHelper.fetchCDDownloadData( + context: context, + userId: userId + ) else { + return [] } let downloads = data.downloadDataTasks() - completion(downloads) + return downloads } } public func getDownloadDataTasksForCourse( - _ courseId: String, - completion: @escaping ([DownloadDataTask]) -> Void - ) { - context.performAndWait { - guard let data = try? fetchCDDownloadData( - predicate: .courseId(courseId) + _ courseId: String + ) async -> [DownloadDataTask] { + let uID = userId + let int32Id = getUserId32() + return await context.perform {[context] in + guard let data = try? CorePersistenceHelper.fetchCDDownloadData( + predicate: .courseId(courseId), + context: context, + userId: int32Id ) else { - completion([]) - return + return [] } if data.isEmpty { - completion([]) - return + return [] } let downloads = data .downloadDataTasks() - .filter(userId: userId) + .filter(userId: uID) - completion(downloads) + return downloads } } - public func downloadDataTask( - for blockId: String, - completion: @escaping (DownloadDataTask?) -> Void - ) { - context.performAndWait { - let data = try? fetchCDDownloadData( - predicate: .id(downloadDataId(from: blockId)) + public func downloadDataTask(for blockId: String) -> DownloadDataTask? { + let dataId = downloadDataId(from: blockId) + let userId = getUserId32() + return context.performAndWait {[context] in + let data = try? CorePersistenceHelper.fetchCDDownloadData( + predicate: .id(dataId), + context: context, + userId: userId ) guard let downloadData = data?.first else { - completion(nil) - return + return nil } - let downloadDataTask = DownloadDataTask(sourse: downloadData) - - completion(downloadDataTask) + return DownloadDataTask(sourse: downloadData) } } - public func downloadDataTask(for blockId: String) -> DownloadDataTask? { - 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 + public func nextBlockForDownloading() async -> DownloadDataTask? { + let userId = getUserId32() + return await context.perform {[context] in + let data = try? CorePersistenceHelper.fetchCDDownloadData( + predicate: .state(DownloadState.finished.rawValue), + fetchLimit: 1, + context: context, + userId: userId + ) + + guard let downloadData = data?.first else { + return nil + } + + return DownloadDataTask(sourse: downloadData) } - - return DownloadDataTask(sourse: downloadData) } public func updateDownloadState( @@ -173,9 +195,13 @@ public class CorePersistence: CorePersistenceProtocol { state: DownloadState, resumeData: Data? ) { - context.performAndWait { - guard let data = try? fetchCDDownloadData( - predicate: .id(downloadDataId(from: id)) + let dataId = downloadDataId(from: id) + let userId = getUserId32() + context.perform {[context] in + guard let data = try? CorePersistenceHelper.fetchCDDownloadData( + predicate: .id(dataId), + context: context, + userId: userId ) else { return } @@ -194,11 +220,15 @@ public class CorePersistence: CorePersistenceProtocol { } } - public func deleteDownloadDataTask(id: String) throws { - context.performAndWait { + public func deleteDownloadDataTask(id: String) async throws { + let dataId = downloadDataId(from: id) + let userId = getUserId32() + return await context.perform {[context] in do { - let records = try fetchCDDownloadData( - predicate: .id(downloadDataId(from: id)) + let records = try CorePersistenceHelper.fetchCDDownloadData( + predicate: .id(dataId), + context: context, + userId: userId ) for record in records { @@ -214,7 +244,7 @@ public class CorePersistence: CorePersistenceProtocol { } public func saveDownloadDataTask(_ task: DownloadDataTask) { - context.performAndWait { + context.perform {[context] in let newDownloadData = CDDownloadData(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump newDownloadData.id = task.id @@ -263,27 +293,6 @@ public class CorePersistence: CorePersistenceProtocol { // 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 diff --git a/OpenEdX/Data/CoursePersistence.swift b/OpenEdX/Data/CoursePersistence.swift index 94ded4e9b..8ba7bc45c 100644 --- a/OpenEdX/Data/CoursePersistence.swift +++ b/OpenEdX/Data/CoursePersistence.swift @@ -18,32 +18,35 @@ public class CoursePersistence: CoursePersistenceProtocol { self.context = context } - public func loadEnrollments() throws -> [CourseItem] { - let result = try? context.fetch(CDCourseItem.fetchRequest()) - .map { - CourseItem(name: $0.name ?? "", - org: $0.org ?? "", - shortDescription: $0.desc ?? "", - imageURL: $0.imageURL ?? "", - hasAccess: $0.hasAccess, - courseStart: $0.courseStart, - courseEnd: $0.courseEnd, - enrollmentStart: $0.enrollmentStart, - enrollmentEnd: $0.enrollmentEnd, - courseID: $0.courseID ?? "", - numPages: Int($0.numPages), - coursesCount: Int($0.courseCount), - progressEarned: 0, - progressPossible: 0)} - if let result, !result.isEmpty { - return result - } else { - throw NoCachedDataError() + public func loadEnrollments() async throws -> [CourseItem] { + try await context.perform { [context] in + let result = try? context.fetch(CDCourseItem.fetchRequest()) + .map { + CourseItem(name: $0.name ?? "", + org: $0.org ?? "", + shortDescription: $0.desc ?? "", + imageURL: $0.imageURL ?? "", + hasAccess: $0.hasAccess, + courseStart: $0.courseStart, + courseEnd: $0.courseEnd, + enrollmentStart: $0.enrollmentStart, + enrollmentEnd: $0.enrollmentEnd, + courseID: $0.courseID ?? "", + numPages: Int($0.numPages), + coursesCount: Int($0.courseCount), + progressEarned: 0, + progressPossible: 0) + } + if let result, !result.isEmpty { + return result + } else { + throw NoCachedDataError() + } } } public func saveEnrollments(items: [CourseItem]) { - context.performAndWait { + context.perform {[context] in for item in items { let newItem = CDCourseItem(context: context) newItem.name = item.name @@ -68,94 +71,97 @@ public class CoursePersistence: CoursePersistenceProtocol { } } - public func loadCourseStructure(courseID: String) throws -> DataLayer.CourseStructure { - let request = CDCourseStructure.fetchRequest() - request.predicate = NSPredicate(format: "id = %@", courseID) - guard let structure = try? context.fetch(request).first else { throw NoCachedDataError() } - - let requestBlocks = CDCourseBlock.fetchRequest() - requestBlocks.predicate = NSPredicate(format: "courseID = %@", courseID) - - let blocks = try? context.fetch(requestBlocks).map { - let userViewData = DataLayer.CourseDetailUserViewData( - transcripts: $0.transcripts?.jsonStringToDictionary() as? [String: String], - encodedVideo: DataLayer.CourseDetailEncodedVideoData( - youTube: DataLayer.EncodedVideoData( - url: $0.youTube?.url, - fileSize: Int($0.youTube?.fileSize ?? 0) - ), - fallback: DataLayer.EncodedVideoData( - url: $0.fallback?.url, - fileSize: Int($0.fallback?.fileSize ?? 0) - ), - desktopMP4: DataLayer.EncodedVideoData( - url: $0.desktopMP4?.url, - fileSize: Int($0.desktopMP4?.fileSize ?? 0) - ), - mobileHigh: DataLayer.EncodedVideoData( - url: $0.mobileHigh?.url, - fileSize: Int($0.mobileHigh?.fileSize ?? 0) - ), - mobileLow: DataLayer.EncodedVideoData( - url: $0.mobileLow?.url, - fileSize: Int($0.mobileLow?.fileSize ?? 0) + public func loadCourseStructure(courseID: String) async throws -> DataLayer.CourseStructure { + try await context.perform {[context] in + let request = CDCourseStructure.fetchRequest() + request.predicate = NSPredicate(format: "id = %@", courseID) + guard let structure = try? context.fetch(request).first else { throw NoCachedDataError() } + + let requestBlocks = CDCourseBlock.fetchRequest() + requestBlocks.predicate = NSPredicate(format: "courseID = %@", courseID) + + let blocks = try? context.fetch(requestBlocks).map { + let userViewData = DataLayer.CourseDetailUserViewData( + transcripts: $0.transcripts?.jsonStringToDictionary() as? [String: String], + encodedVideo: DataLayer.CourseDetailEncodedVideoData( + youTube: DataLayer.EncodedVideoData( + url: $0.youTube?.url, + fileSize: Int($0.youTube?.fileSize ?? 0) + ), + fallback: DataLayer.EncodedVideoData( + url: $0.fallback?.url, + fileSize: Int($0.fallback?.fileSize ?? 0) + ), + desktopMP4: DataLayer.EncodedVideoData( + url: $0.desktopMP4?.url, + fileSize: Int($0.desktopMP4?.fileSize ?? 0) + ), + mobileHigh: DataLayer.EncodedVideoData( + url: $0.mobileHigh?.url, + fileSize: Int($0.mobileHigh?.fileSize ?? 0) + ), + mobileLow: DataLayer.EncodedVideoData( + url: $0.mobileLow?.url, + fileSize: Int($0.mobileLow?.fileSize ?? 0) + ), + hls: DataLayer.EncodedVideoData( + url: $0.hls?.url, + fileSize: Int($0.hls?.fileSize ?? 0) + ) ), - hls: DataLayer.EncodedVideoData( - url: $0.hls?.url, - fileSize: Int($0.hls?.fileSize ?? 0) + topicID: "" + ) + return DataLayer.CourseBlock( + blockId: $0.blockId ?? "", + id: $0.id ?? "", + graded: $0.graded, + due: $0.due, + completion: $0.completion, + studentUrl: $0.studentUrl ?? "", + webUrl: $0.webUrl ?? "", + type: $0.type ?? "", + displayName: $0.displayName ?? "", + descendants: $0.descendants, + allSources: $0.allSources, + userViewData: userViewData, + multiDevice: $0.multiDevice, + assignmentProgress: DataLayer.AssignmentProgress( + assignmentType: $0.assignmentType, + numPointsEarned: $0.numPointsEarned, + numPointsPossible: $0.numPointsPossible + ) + ) + } + + let dictionary = blocks?.reduce(into: [:]) { result, block in + result[block.id] = block + } ?? [:] + + return DataLayer.CourseStructure( + rootItem: structure.rootItem ?? "", + dict: dictionary, + id: structure.id ?? "", + media: DataLayer.CourseMedia( + image: DataLayer.Image( + raw: structure.mediaRaw ?? "", + small: structure.mediaSmall ?? "", + large: structure.mediaLarge ?? "" ) ), - topicID: "" - ) - return DataLayer.CourseBlock( - blockId: $0.blockId ?? "", - id: $0.id ?? "", - graded: $0.graded, - due: $0.due, - completion: $0.completion, - studentUrl: $0.studentUrl ?? "", - webUrl: $0.webUrl ?? "", - type: $0.type ?? "", - displayName: $0.displayName ?? "", - descendants: $0.descendants, - allSources: $0.allSources, - userViewData: userViewData, - multiDevice: $0.multiDevice, - assignmentProgress: DataLayer.AssignmentProgress( - assignmentType: $0.assignmentType, - numPointsEarned: $0.numPointsEarned, - numPointsPossible: $0.numPointsPossible + certificate: DataLayer.Certificate(url: structure.certificate), + org: structure.org ?? "", + isSelfPaced: structure.isSelfPaced, + courseProgress: DataLayer.CourseProgress( + assignmentsCompleted: Int(structure.assignmentsCompleted), + totalAssignmentsCount: Int(structure.totalAssignmentsCount) ) ) } - let dictionary = blocks?.reduce(into: [:]) { result, block in - result[block.id] = block - } ?? [:] - - return DataLayer.CourseStructure( - rootItem: structure.rootItem ?? "", - dict: dictionary, - id: structure.id ?? "", - media: DataLayer.CourseMedia( - image: DataLayer.Image( - raw: structure.mediaRaw ?? "", - small: structure.mediaSmall ?? "", - large: structure.mediaLarge ?? "" - ) - ), - certificate: DataLayer.Certificate(url: structure.certificate), - org: structure.org ?? "", - isSelfPaced: structure.isSelfPaced, - courseProgress: DataLayer.CourseProgress( - assignmentsCompleted: Int(structure.assignmentsCompleted), - totalAssignmentsCount: Int(structure.totalAssignmentsCount) - ) - ) } public func saveCourseStructure(structure: DataLayer.CourseStructure) { - context.performAndWait { + context.perform {[context] in context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump let newStructure = CDCourseStructure(context: self.context) newStructure.certificate = structure.certificate?.url @@ -256,7 +262,7 @@ public class CoursePersistence: CoursePersistenceProtocol { } public func saveSubtitles(url: String, subtitlesString: String) { - context.performAndWait { + context.perform {[context] in let newSubtitle = CDSubtitle(context: context) newSubtitle.url = url newSubtitle.subtitle = subtitlesString @@ -270,16 +276,18 @@ public class CoursePersistence: CoursePersistenceProtocol { } } - public func loadSubtitles(url: String) -> String? { - let request = CDSubtitle.fetchRequest() - request.predicate = NSPredicate(format: "url = %@", url) - - guard let subtitle = try? context.fetch(request).first, - let loaded = subtitle.uploadedAt else { return nil } - if Date().timeIntervalSince1970 - loaded.timeIntervalSince1970 < 5 * 3600 { - return subtitle.subtitle ?? "" + public func loadSubtitles(url: String) async -> String? { + await context.perform {[context] in + let request = CDSubtitle.fetchRequest() + request.predicate = NSPredicate(format: "url = %@", url) + + guard let subtitle = try? context.fetch(request).first, + let loaded = subtitle.uploadedAt else { return nil } + if Date().timeIntervalSince1970 - loaded.timeIntervalSince1970 < 5 * 3600 { + return subtitle.subtitle ?? "" + } + return nil } - return nil } public func saveCourseDates(courseID: String, courseDates: CourseDates) { diff --git a/OpenEdX/Data/DashboardPersistence.swift b/OpenEdX/Data/DashboardPersistence.swift index b7e0f062a..2ac59a583 100644 --- a/OpenEdX/Data/DashboardPersistence.swift +++ b/OpenEdX/Data/DashboardPersistence.swift @@ -18,32 +18,34 @@ public class DashboardPersistence: DashboardPersistenceProtocol { self.context = context } - public func loadEnrollments() throws -> [CourseItem] { - let result = try? context.fetch(CDDashboardCourse.fetchRequest()) - .map { CourseItem(name: $0.name ?? "", - org: $0.org ?? "", - shortDescription: $0.desc ?? "", - imageURL: $0.imageURL ?? "", - hasAccess: $0.hasAccess, - courseStart: $0.courseStart, - courseEnd: $0.courseEnd, - enrollmentStart: $0.enrollmentStart, - enrollmentEnd: $0.enrollmentEnd, - courseID: $0.courseID ?? "", - numPages: Int($0.numPages), - coursesCount: Int($0.courseCount), - progressEarned: 0, - progressPossible: 0)} - if let result, !result.isEmpty { - return result - } else { - throw NoCachedDataError() + public func loadEnrollments() async throws -> [CourseItem] { + try await context.perform {[context] in + let result = try? context.fetch(CDDashboardCourse.fetchRequest()) + .map { CourseItem(name: $0.name ?? "", + org: $0.org ?? "", + shortDescription: $0.desc ?? "", + imageURL: $0.imageURL ?? "", + hasAccess: $0.hasAccess, + courseStart: $0.courseStart, + courseEnd: $0.courseEnd, + enrollmentStart: $0.enrollmentStart, + enrollmentEnd: $0.enrollmentEnd, + courseID: $0.courseID ?? "", + numPages: Int($0.numPages), + coursesCount: Int($0.courseCount), + progressEarned: 0, + progressPossible: 0)} + if let result, !result.isEmpty { + return result + } else { + throw NoCachedDataError() + } } } public func saveEnrollments(items: [CourseItem]) { for item in items { - context.performAndWait { + context.perform {[context] in let newItem = CDDashboardCourse(context: self.context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump newItem.name = item.name @@ -67,89 +69,90 @@ public class DashboardPersistence: DashboardPersistenceProtocol { } } - public func loadPrimaryEnrollment() throws -> PrimaryEnrollment { - let request = CDMyEnrollments.fetchRequest() - if let result = try context.fetch(request).first { - let primaryCourse = result.primaryCourse.flatMap { cdPrimaryCourse -> PrimaryCourse? in + public func loadPrimaryEnrollment() async throws -> PrimaryEnrollment { + try await context.perform {[context] in + let request = CDMyEnrollments.fetchRequest() + if let result = try context.fetch(request).first { + let primaryCourse = result.primaryCourse.flatMap { cdPrimaryCourse -> PrimaryCourse? in - let futureAssignments = (cdPrimaryCourse.futureAssignments as? Set ?? []) - .map { future in - return Assignment( - type: future.type ?? "", - title: future.title ?? "", - description: future.descript ?? "", - date: future.date ?? Date(), - complete: future.complete, - firstComponentBlockId: future.firstComponentBlockId - ) - } - - let pastAssignments = (cdPrimaryCourse.pastAssignments as? Set ?? []) - .map { past in - return Assignment( - type: past.type ?? "", - title: past.title ?? "", - description: past.descript ?? "", - date: past.date ?? Date(), - complete: past.complete, - firstComponentBlockId: past.firstComponentBlockId + let futureAssignments = (cdPrimaryCourse.futureAssignments as? Set ?? []) + .map { future in + return Assignment( + type: future.type ?? "", + title: future.title ?? "", + description: future.descript ?? "", + date: future.date ?? Date(), + complete: future.complete, + firstComponentBlockId: future.firstComponentBlockId + ) + } + + let pastAssignments = (cdPrimaryCourse.pastAssignments as? Set ?? []) + .map { past in + return Assignment( + type: past.type ?? "", + title: past.title ?? "", + description: past.descript ?? "", + date: past.date ?? Date(), + complete: past.complete, + firstComponentBlockId: past.firstComponentBlockId + ) + } + + return PrimaryCourse( + name: cdPrimaryCourse.name ?? "", + org: cdPrimaryCourse.org ?? "", + courseID: cdPrimaryCourse.courseID ?? "", + hasAccess: cdPrimaryCourse.hasAccess, + courseStart: cdPrimaryCourse.courseStart, + courseEnd: cdPrimaryCourse.courseEnd, + courseBanner: cdPrimaryCourse.courseBanner ?? "", + futureAssignments: futureAssignments, + pastAssignments: pastAssignments, + progressEarned: Int(cdPrimaryCourse.progressEarned), + progressPossible: Int(cdPrimaryCourse.progressPossible), + lastVisitedBlockID: cdPrimaryCourse.lastVisitedBlockID ?? "", + resumeTitle: cdPrimaryCourse.resumeTitle + ) + } + + let courses = (result.courses as? Set ?? []) + .map { cdCourse in + return CourseItem( + name: cdCourse.name ?? "", + org: cdCourse.org ?? "", + shortDescription: cdCourse.desc ?? "", + imageURL: cdCourse.imageURL ?? "", + hasAccess: cdCourse.hasAccess, + courseStart: cdCourse.courseStart, + courseEnd: cdCourse.courseEnd, + enrollmentStart: cdCourse.enrollmentStart, + enrollmentEnd: cdCourse.enrollmentEnd, + courseID: cdCourse.courseID ?? "", + numPages: Int(cdCourse.numPages), + coursesCount: Int(cdCourse.courseCount), + progressEarned: Int(cdCourse.progressEarned), + progressPossible: Int(cdCourse.progressPossible) ) } - return PrimaryCourse( - name: cdPrimaryCourse.name ?? "", - org: cdPrimaryCourse.org ?? "", - courseID: cdPrimaryCourse.courseID ?? "", - hasAccess: cdPrimaryCourse.hasAccess, - courseStart: cdPrimaryCourse.courseStart, - courseEnd: cdPrimaryCourse.courseEnd, - courseBanner: cdPrimaryCourse.courseBanner ?? "", - futureAssignments: futureAssignments, - pastAssignments: pastAssignments, - progressEarned: Int(cdPrimaryCourse.progressEarned), - progressPossible: Int(cdPrimaryCourse.progressPossible), - lastVisitedBlockID: cdPrimaryCourse.lastVisitedBlockID ?? "", - resumeTitle: cdPrimaryCourse.resumeTitle + return PrimaryEnrollment( + primaryCourse: primaryCourse, + courses: courses, + totalPages: Int(result.totalPages), + count: Int(result.count) ) + } else { + throw NoCachedDataError() } - - let courses = (result.courses as? Set ?? []) - .map { cdCourse in - return CourseItem( - name: cdCourse.name ?? "", - org: cdCourse.org ?? "", - shortDescription: cdCourse.desc ?? "", - imageURL: cdCourse.imageURL ?? "", - hasAccess: cdCourse.hasAccess, - courseStart: cdCourse.courseStart, - courseEnd: cdCourse.courseEnd, - enrollmentStart: cdCourse.enrollmentStart, - enrollmentEnd: cdCourse.enrollmentEnd, - courseID: cdCourse.courseID ?? "", - numPages: Int(cdCourse.numPages), - coursesCount: Int(cdCourse.courseCount), - progressEarned: Int(cdCourse.progressEarned), - progressPossible: Int(cdCourse.progressPossible) - ) - } - - return PrimaryEnrollment( - primaryCourse: primaryCourse, - courses: courses, - totalPages: Int(result.totalPages), - count: Int(result.count) - ) - } else { - throw NoCachedDataError() } } // swiftlint:disable function_body_length public func savePrimaryEnrollment(enrollments: PrimaryEnrollment) { - context.performAndWait { - // Deleting all old data before saving new ones - clearOldEnrollmentsData() - + // Deleting all old data before saving new ones + clearOldEnrollmentsData() + context.perform {[context] in let newEnrollment = CDMyEnrollments(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump @@ -228,21 +231,23 @@ public class DashboardPersistence: DashboardPersistenceProtocol { // swiftlint:enable function_body_length func clearOldEnrollmentsData() { - let fetchRequest1: NSFetchRequest = CDDashboardCourse.fetchRequest() - let batchDeleteRequest1 = NSBatchDeleteRequest(fetchRequest: fetchRequest1) + context.perform {[context] in + let fetchRequest1: NSFetchRequest = CDDashboardCourse.fetchRequest() + let batchDeleteRequest1 = NSBatchDeleteRequest(fetchRequest: fetchRequest1) - let fetchRequest2: NSFetchRequest = CDPrimaryCourse.fetchRequest() - let batchDeleteRequest2 = NSBatchDeleteRequest(fetchRequest: fetchRequest2) + let fetchRequest2: NSFetchRequest = CDPrimaryCourse.fetchRequest() + let batchDeleteRequest2 = NSBatchDeleteRequest(fetchRequest: fetchRequest2) - let fetchRequest3: NSFetchRequest = CDMyEnrollments.fetchRequest() - let batchDeleteRequest3 = NSBatchDeleteRequest(fetchRequest: fetchRequest3) + let fetchRequest3: NSFetchRequest = CDMyEnrollments.fetchRequest() + let batchDeleteRequest3 = NSBatchDeleteRequest(fetchRequest: fetchRequest3) - do { - try context.execute(batchDeleteRequest1) - try context.execute(batchDeleteRequest2) - try context.execute(batchDeleteRequest3) - } catch { - print("Error when deleting old data:", error) + do { + try context.execute(batchDeleteRequest1) + try context.execute(batchDeleteRequest2) + try context.execute(batchDeleteRequest3) + } catch { + print("Error when deleting old data:", error) + } } } } diff --git a/OpenEdX/Data/DiscoveryPersistence.swift b/OpenEdX/Data/DiscoveryPersistence.swift index 2e6d443bd..b36f58fad 100644 --- a/OpenEdX/Data/DiscoveryPersistence.swift +++ b/OpenEdX/Data/DiscoveryPersistence.swift @@ -18,32 +18,34 @@ public class DiscoveryPersistence: DiscoveryPersistenceProtocol { self.context = context } - public func loadDiscovery() throws -> [CourseItem] { - let result = try? context.fetch(CDDiscoveryCourse.fetchRequest()) - .map { CourseItem(name: $0.name ?? "", - org: $0.org ?? "", - shortDescription: $0.desc ?? "", - imageURL: $0.imageURL ?? "", - hasAccess: $0.hasAccess, - courseStart: $0.courseStart, - courseEnd: $0.courseEnd, - enrollmentStart: $0.enrollmentStart, - enrollmentEnd: $0.enrollmentEnd, - courseID: $0.courseID ?? "", - numPages: Int($0.numPages), - coursesCount: Int($0.courseCount), - progressEarned: 0, - progressPossible: 0)} - if let result, !result.isEmpty { - return result - } else { - throw NoCachedDataError() + public func loadDiscovery() async throws -> [CourseItem] { + try await context.perform {[context] in + let result = try? context.fetch(CDDiscoveryCourse.fetchRequest()) + .map { CourseItem(name: $0.name ?? "", + org: $0.org ?? "", + shortDescription: $0.desc ?? "", + imageURL: $0.imageURL ?? "", + hasAccess: $0.hasAccess, + courseStart: $0.courseStart, + courseEnd: $0.courseEnd, + enrollmentStart: $0.enrollmentStart, + enrollmentEnd: $0.enrollmentEnd, + courseID: $0.courseID ?? "", + numPages: Int($0.numPages), + coursesCount: Int($0.courseCount), + progressEarned: 0, + progressPossible: 0)} + if let result, !result.isEmpty { + return result + } else { + throw NoCachedDataError() + } } } public func saveDiscovery(items: [CourseItem]) { for item in items { - context.performAndWait { + context.perform {[context] in let newItem = CDDiscoveryCourse(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump newItem.name = item.name @@ -67,28 +69,30 @@ public class DiscoveryPersistence: DiscoveryPersistenceProtocol { } } - public func loadCourseDetails(courseID: String) throws -> CourseDetails { - let request = CDCourseDetails.fetchRequest() - request.predicate = NSPredicate(format: "courseID = %@", courseID) - guard let courseDetails = try? context.fetch(request).first else { throw NoCachedDataError() } - return CourseDetails( - courseID: courseDetails.courseID ?? "", - org: courseDetails.org ?? "", - courseTitle: courseDetails.courseTitle ?? "", - courseDescription: courseDetails.courseDescription ?? "", - courseStart: courseDetails.courseStart, - courseEnd: courseDetails.courseEnd, - enrollmentStart: courseDetails.enrollmentStart, - enrollmentEnd: courseDetails.enrollmentEnd, - isEnrolled: courseDetails.isEnrolled, - overviewHTML: courseDetails.overviewHTML ?? "", - courseBannerURL: courseDetails.courseBannerURL ?? "", - courseVideoURL: nil - ) + public func loadCourseDetails(courseID: String) async throws -> CourseDetails { + try await context.perform {[context] in + let request = CDCourseDetails.fetchRequest() + request.predicate = NSPredicate(format: "courseID = %@", courseID) + guard let courseDetails = try? context.fetch(request).first else { throw NoCachedDataError() } + return CourseDetails( + courseID: courseDetails.courseID ?? "", + org: courseDetails.org ?? "", + courseTitle: courseDetails.courseTitle ?? "", + courseDescription: courseDetails.courseDescription ?? "", + courseStart: courseDetails.courseStart, + courseEnd: courseDetails.courseEnd, + enrollmentStart: courseDetails.enrollmentStart, + enrollmentEnd: courseDetails.enrollmentEnd, + isEnrolled: courseDetails.isEnrolled, + overviewHTML: courseDetails.overviewHTML ?? "", + courseBannerURL: courseDetails.courseBannerURL ?? "", + courseVideoURL: nil + ) + } } public func saveCourseDetails(course: CourseDetails) { - context.performAndWait { + context.perform {[context] in let newCourseDetails = CDCourseDetails(context: self.context) newCourseDetails.courseID = course.courseID newCourseDetails.org = course.org From 5f4268c556d12aa6478b7c023eb2a1c1276b782a Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Tue, 9 Jul 2024 12:48:39 +0500 Subject: [PATCH 27/55] chore: theme enhancements (#476) * chore: theme enhancement * chore: theme enhancements * chore: theme improvements * chore: address design team feedback * chore: adding new color for coursecardbackground * fix: using correct colors for openedX * fix: using irreversibleAlert in registration to highlight errors * fix: comment added pop-up message text is not visible in dark mode * refactor: fix typo --- Core/Core/View/Base/AlertView.swift | 19 +++------- Core/Core/View/Base/CourseCellView.swift | 2 +- .../View/Base/LogistrationBottomView.swift | 2 +- Core/Core/View/Base/PickerView.swift | 4 +- .../View/Base/RegistrationTextField.swift | 8 ++-- Core/Core/View/Base/UnitButtonView.swift | 29 +++++++------- .../Presentation/Handouts/HandoutsView.swift | 3 ++ .../Subviews/CustomDisclosureGroup.swift | 2 +- .../Elements/CourseCardView.swift | 2 +- .../Presentation/Elements/DropDownMenu.swift | 3 +- .../Elements/PrimaryCardView.swift | 6 +-- .../Elements/ProgressLineView.swift | 2 +- .../Comments/Base/CommentCell.swift | 6 +-- .../Comments/Base/ParentCommentView.swift | 6 +-- .../Comments/Thread/ThreadView.swift | 2 +- .../Elements/NewCalendarView.swift | 15 ++++---- .../Presentation/Profile/ProfileView.swift | 2 +- .../Settings/ManageAccountView.swift | 2 +- .../Contents.json | 38 +++++++++++++++++++ .../Colors/SecondaryButton/Contents.json | 6 +++ .../Contents.json | 38 +++++++++++++++++++ .../Contents.json | 0 .../Contents.json | 0 Theme/Theme/SwiftGen/ThemeAssets.swift | 2 + Theme/Theme/Theme.swift | 2 + .../Elements/WhatsNewNavigationButton.swift | 2 +- 26 files changed, 143 insertions(+), 60 deletions(-) create mode 100644 Theme/Theme/Assets.xcassets/Colors/CourseCardBackground.colorset/Contents.json create mode 100644 Theme/Theme/Assets.xcassets/Colors/SecondaryButton/Contents.json create mode 100644 Theme/Theme/Assets.xcassets/Colors/SecondaryButton/SecondaryButtonBGColor.colorset/Contents.json rename Theme/Theme/Assets.xcassets/Colors/{ => SecondaryButton}/SecondaryButtonBorderColor.colorset/Contents.json (100%) rename Theme/Theme/Assets.xcassets/Colors/{ => SecondaryButton}/SecondaryButtonTextColor.colorset/Contents.json (100%) diff --git a/Core/Core/View/Base/AlertView.swift b/Core/Core/View/Base/AlertView.swift index 388c70c54..286fdd2e7 100644 --- a/Core/Core/View/Base/AlertView.swift +++ b/Core/Core/View/Base/AlertView.swift @@ -109,15 +109,8 @@ public struct AlertView: View { .fixedSize(horizontal: false, vertical: false) ) .overlay( - RoundedRectangle(cornerRadius: 12) - .stroke( - style: .init( - lineWidth: 1, - lineCap: .round, - lineJoin: .round, - miterLimit: 1 - ) - ) + Theme.Shapes.buttonShape + .stroke(lineWidth: 1) .foregroundColor(Theme.Colors.backgroundStroke) .fixedSize(horizontal: false, vertical: false) ) @@ -256,7 +249,7 @@ public struct AlertView: View { .frame(maxWidth: 215) } UnitButtonView(type: .custom(action), - bgColor: .clear, + bgColor: Theme.Colors.secondaryButtonBGColor, action: { okTapped() }) .frame(maxWidth: 215) @@ -417,7 +410,7 @@ public struct AlertView: View { } label: { ZStack { Text(primaryButtonTitle) - .foregroundColor(Theme.Colors.primaryButtonTextColor) + .foregroundColor(Theme.Colors.styledButtonText) .font(Theme.Fonts.labelLarge) .frame(maxWidth: .infinity) .padding(.horizontal, 16) @@ -426,7 +419,7 @@ public struct AlertView: View { } .background( Theme.Shapes.buttonShape - .fill(Theme.Colors.accentColor) + .fill(Theme.Colors.accentButtonColor) ) .overlay( RoundedRectangle(cornerRadius: 8) @@ -454,7 +447,7 @@ public struct AlertView: View { }) .background( Theme.Shapes.buttonShape - .fill(.clear) + .fill(Theme.Colors.secondaryButtonBGColor) ) .overlay( RoundedRectangle(cornerRadius: 8) diff --git a/Core/Core/View/Base/CourseCellView.swift b/Core/Core/View/Base/CourseCellView.swift index 72f02b2bb..a996c3383 100644 --- a/Core/Core/View/Base/CourseCellView.swift +++ b/Core/Core/View/Base/CourseCellView.swift @@ -116,7 +116,7 @@ public struct CourseCellView: View { .overlay(Theme.Colors.cardViewStroke) .padding(.vertical, 18) .padding(.horizontal, 3) - .accessibilityIdentifier("devider") + .accessibilityIdentifier("divider") } } } diff --git a/Core/Core/View/Base/LogistrationBottomView.swift b/Core/Core/View/Base/LogistrationBottomView.swift index fca95cc04..fc0aa0ef4 100644 --- a/Core/Core/View/Base/LogistrationBottomView.swift +++ b/Core/Core/View/Base/LogistrationBottomView.swift @@ -48,7 +48,7 @@ public struct LogistrationBottomView: View { action: { action(.signIn) }, - color: Theme.Colors.background, + color: Theme.Colors.secondaryButtonBGColor, textColor: Theme.Colors.secondaryButtonTextColor, borderColor: Theme.Colors.secondaryButtonBorderColor ) diff --git a/Core/Core/View/Base/PickerView.swift b/Core/Core/View/Base/PickerView.swift index d0655ddb4..e0403b822 100644 --- a/Core/Core/View/Base/PickerView.swift +++ b/Core/Core/View/Base/PickerView.swift @@ -64,7 +64,7 @@ public struct PickerView: View { .stroke(lineWidth: 1) .fill(config.error == "" ? Theme.Colors.textInputStroke - : Theme.Colors.alert) + : Theme.Colors.irreversibleAlert) ) .shake($config.shake) Text(config.error == "" ? config.field.instructions @@ -72,7 +72,7 @@ public struct PickerView: View { .font(Theme.Fonts.labelMedium) .foregroundColor(config.error == "" ? Theme.Colors.textPrimary - : Theme.Colors.alert) + : Theme.Colors.irreversibleAlert) .accessibilityIdentifier("\(config.field.name)_instructions_text") } } diff --git a/Core/Core/View/Base/RegistrationTextField.swift b/Core/Core/View/Base/RegistrationTextField.swift index 9ed039763..45a8345ce 100644 --- a/Core/Core/View/Base/RegistrationTextField.swift +++ b/Core/Core/View/Base/RegistrationTextField.swift @@ -60,7 +60,7 @@ public struct RegistrationTextField: View { .fill( config.error == "" ? Theme.Colors.textInputStroke - : Theme.Colors.alert + : Theme.Colors.irreversibleAlert ) ) .shake($config.shake) @@ -83,7 +83,7 @@ public struct RegistrationTextField: View { .fill( config.error == "" ? Theme.Colors.textInputStroke - : Theme.Colors.alert + : Theme.Colors.irreversibleAlert ) ) .shake($config.shake) @@ -107,7 +107,7 @@ public struct RegistrationTextField: View { .fill( config.error == "" ? Theme.Colors.textInputStroke - : Theme.Colors.alert + : Theme.Colors.irreversibleAlert ) ) .shake($config.shake) @@ -119,7 +119,7 @@ public struct RegistrationTextField: View { .font(Theme.Fonts.bodySmall) .foregroundColor(config.error == "" ? Theme.Colors.textSecondaryLight - : Theme.Colors.alert) + : Theme.Colors.irreversibleAlert) .accessibilityIdentifier("\(config.field.name)_instructions_text") } } diff --git a/Core/Core/View/Base/UnitButtonView.swift b/Core/Core/View/Base/UnitButtonView.swift index d347cde97..6778bb8dd 100644 --- a/Core/Core/View/Base/UnitButtonView.swift +++ b/Core/Core/View/Base/UnitButtonView.swift @@ -75,23 +75,23 @@ public struct UnitButtonView: View { case .first: HStack { Text(type.stringValue()) - .foregroundColor(Theme.Colors.styledButtonText) + .foregroundColor(Theme.Colors.primaryButtonTextColor) .font(Theme.Fonts.labelLarge) CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) - .foregroundColor(Theme.Colors.styledButtonText) + .foregroundColor(Theme.Colors.primaryButtonTextColor) .rotationEffect(Angle.degrees(nextButtonDegrees)) }.padding(.horizontal, 16) case .next, .nextBig: HStack { Text(type.stringValue()) - .foregroundColor(Theme.Colors.styledButtonText) + .foregroundColor(Theme.Colors.primaryButtonTextColor) .padding(.leading, 20) .font(Theme.Fonts.labelLarge) if type != .nextBig { Spacer() } CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) - .foregroundColor(Theme.Colors.styledButtonText) + .foregroundColor(Theme.Colors.primaryButtonTextColor) .rotationEffect(Angle.degrees(nextButtonDegrees)) .padding(.trailing, 20) } @@ -99,19 +99,19 @@ public struct UnitButtonView: View { HStack { if isVerticalNavigation { Text(type.stringValue()) - .foregroundColor(Theme.Colors.secondaryButtonTextColor) + .foregroundColor(Theme.Colors.accentColor) .font(Theme.Fonts.labelLarge) .padding(.leading, 20) CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) .rotationEffect(Angle.degrees(90)) .padding(.trailing, 20) - .foregroundColor(Theme.Colors.secondaryButtonTextColor) + .foregroundColor(Theme.Colors.accentColor) } else { CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) .padding(.leading, 20) - .foregroundColor(Theme.Colors.secondaryButtonTextColor) + .foregroundColor(Theme.Colors.accentColor) Text(type.stringValue()) - .foregroundColor(Theme.Colors.secondaryButtonTextColor) + .foregroundColor(Theme.Colors.accentColor) .font(Theme.Fonts.labelLarge) .padding(.trailing, 20) } @@ -119,22 +119,22 @@ public struct UnitButtonView: View { case .last: HStack { Text(type.stringValue()) - .foregroundColor(Theme.Colors.styledButtonText) + .foregroundColor(Theme.Colors.primaryButtonTextColor) .padding(.leading, 8) .font(Theme.Fonts.labelLarge) .scaledToFit() Spacer() CoreAssets.check.swiftUIImage.renderingMode(.template) - .foregroundColor(Theme.Colors.styledButtonText) + .foregroundColor(Theme.Colors.primaryButtonTextColor) .padding(.trailing, 8) } case .finish: HStack { Text(type.stringValue()) - .foregroundColor(Theme.Colors.styledButtonText) + .foregroundColor(Theme.Colors.primaryButtonTextColor) .font(Theme.Fonts.labelLarge) CoreAssets.check.swiftUIImage.renderingMode(.template) - .foregroundColor(Theme.Colors.styledButtonText) + .foregroundColor(Theme.Colors.primaryButtonTextColor) }.padding(.horizontal, 16) case .reload, .custom: VStack(alignment: .center) { @@ -163,7 +163,7 @@ public struct UnitButtonView: View { Theme.Shapes.buttonShape .fill(type == .previous ? Theme.Colors.background - : Theme.Colors.accentButtonColor) + : Theme.Colors.accentColor) .shadow(color: Color.black.opacity(0.25), radius: 21, y: 4) .overlay( Theme.Shapes.buttonShape @@ -174,8 +174,7 @@ public struct UnitButtonView: View { miterLimit: 1) ) .foregroundColor( - type == .previous ? Theme.Colors.secondaryButtonBorderColor - : Theme.Colors.accentButtonColor + Theme.Colors.accentColor ) ) diff --git a/Course/Course/Presentation/Handouts/HandoutsView.swift b/Course/Course/Presentation/Handouts/HandoutsView.swift index c9f7460de..c9a2ed6cf 100644 --- a/Course/Course/Presentation/Handouts/HandoutsView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsView.swift @@ -61,6 +61,9 @@ struct HandoutsView: View { ) }) Divider() + .frame(height: 1) + .overlay(Theme.Colors.cardViewStroke) + .accessibilityIdentifier("divider") HandoutsItemCell(type: .announcements, onTapAction: { if !viewModel.updates.isEmpty { viewModel.router.showHandoutsUpdatesView( diff --git a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift index 10ab50625..8f152fdd9 100644 --- a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift +++ b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift @@ -174,7 +174,7 @@ struct CustomDisclosureGroup: View { .padding(.vertical, 12) .background( RoundedRectangle(cornerRadius: 8) - .fill(Theme.Colors.tabbarColor) + .fill(Theme.Colors.datesSectionBackground) ) .overlay( RoundedRectangle(cornerRadius: 8) diff --git a/Dashboard/Dashboard/Presentation/Elements/CourseCardView.swift b/Dashboard/Dashboard/Presentation/Elements/CourseCardView.swift index 53a0911db..2c93c7d33 100644 --- a/Dashboard/Dashboard/Presentation/Elements/CourseCardView.swift +++ b/Dashboard/Dashboard/Presentation/Elements/CourseCardView.swift @@ -66,7 +66,7 @@ struct CourseCardView: View { .padding(8) } } - .background(Theme.Colors.background) + .background(Theme.Colors.courseCardBackground) .cornerRadius(8) .shadow(color: Theme.Colors.courseCardShadow, radius: 6, x: 2, y: 2) } diff --git a/Dashboard/Dashboard/Presentation/Elements/DropDownMenu.swift b/Dashboard/Dashboard/Presentation/Elements/DropDownMenu.swift index 85ba10548..beaf3bec5 100644 --- a/Dashboard/Dashboard/Presentation/Elements/DropDownMenu.swift +++ b/Dashboard/Dashboard/Presentation/Elements/DropDownMenu.swift @@ -58,7 +58,8 @@ struct DropDownMenu: View { Text(option.text) .font(Theme.Fonts.titleSmall) .foregroundColor( - option == selectedOption ? Theme.Colors.white : Theme.Colors.textPrimary + option == selectedOption ? Theme.Colors.primaryButtonTextColor : + Theme.Colors.textPrimary ) Spacer() } diff --git a/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift b/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift index 27e2abfb5..fc18526a9 100644 --- a/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift +++ b/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift @@ -73,7 +73,7 @@ public struct PrimaryCardView: View { assignments } } - .background(Theme.Colors.background) + .background(Theme.Colors.courseCardBackground) .cornerRadius(8) .shadow(color: Theme.Colors.courseCardShadow, radius: 4, x: 0, y: 3) .padding(20) @@ -206,12 +206,12 @@ public struct PrimaryCardView: View { } .padding(.top, 8) .padding(.bottom, selected ? 10 : 0) - }.background(selected ? Theme.Colors.accentColor : .clear) + }.background(selected ? Theme.Colors.accentButtonColor : .clear) }) } private func foregroundColor(_ selected: Bool) -> SwiftUI.Color { - return selected ? Theme.Colors.primaryButtonTextColor : Theme.Colors.textPrimary + return selected ? Theme.Colors.white : Theme.Colors.textPrimary } private var courseBanner: some View { diff --git a/Dashboard/Dashboard/Presentation/Elements/ProgressLineView.swift b/Dashboard/Dashboard/Presentation/Elements/ProgressLineView.swift index 611ddbcb1..80ef325a1 100644 --- a/Dashboard/Dashboard/Presentation/Elements/ProgressLineView.swift +++ b/Dashboard/Dashboard/Presentation/Elements/ProgressLineView.swift @@ -30,7 +30,7 @@ struct ProgressLineView: View { Rectangle() .foregroundStyle(Theme.Colors.cardViewStroke) Rectangle() - .foregroundStyle(Theme.Colors.accentColor) + .foregroundStyle(Theme.Colors.accentButtonColor) .frame(width: geometry.size.width * progressValue) }.frame(height: height) } diff --git a/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift b/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift index ff11baedd..7d8c8b155 100644 --- a/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift +++ b/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift @@ -118,13 +118,13 @@ public struct CommentCell: View { onLikeTap() }, label: { comment.voted - ? CoreAssets.voted.swiftUIImage - : CoreAssets.vote.swiftUIImage + ? (CoreAssets.voted.swiftUIImage.renderingMode(.template)) + : CoreAssets.vote.swiftUIImage.renderingMode(.template) Text("\(comment.votesCount)") Text(DiscussionLocalization.votesCount(comment.votesCount)) .font(Theme.Fonts.labelLarge) }).foregroundColor(comment.voted - ? Theme.Colors.accentColor + ? Theme.Colors.accentXColor : Theme.Colors.textSecondary) Spacer() diff --git a/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift b/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift index 23aae3d52..3594f26c5 100644 --- a/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift +++ b/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift @@ -107,15 +107,15 @@ public struct ParentCommentView: View { onLikeTap() }, label: { comments.voted - ? CoreAssets.voted.swiftUIImage - : CoreAssets.vote.swiftUIImage + ? (CoreAssets.voted.swiftUIImage.renderingMode(.template)) + : (CoreAssets.vote.swiftUIImage.renderingMode(.template)) 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.accentXColor : Theme.Colors.textSecondaryLight) Spacer() Button(action: { diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index 84f2b6816..b764ed0d6 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -210,7 +210,7 @@ public struct ThreadView: View { Text(viewModel.alertMessage ?? "") .shadowCardStyle( bgColor: Theme.Colors.accentColor, - textColor: Theme.Colors.white + textColor: Theme.Colors.primaryButtonTextColor ) .padding(.top, 80) Spacer() diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift b/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift index 64d9342a1..264914c75 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift @@ -112,13 +112,14 @@ struct NewCalendarView: View { ) VStack(spacing: 16) { - StyledButton(ProfileLocalization.Calendar.cancel, - action: { - onCloseTapped() - }, - color: Theme.Colors.background, - textColor: Theme.Colors.accentColor, - borderColor: Theme.Colors.accentColor + StyledButton( + ProfileLocalization.Calendar.cancel, + action: { + onCloseTapped() + }, + color: Theme.Colors.background, + textColor: Theme.Colors.accentColor, + borderColor: Theme.Colors.accentColor ) StyledButton(ProfileLocalization.Calendar.beginSyncing) { diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index 5b2a04c77..37a23c6de 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -106,7 +106,7 @@ public struct ProfileView: View { } ) }, - color: .clear, + color: Theme.Colors.background, textColor: Theme.Colors.accentColor, borderColor: Theme.Colors.accentColor ).padding(.all, 24) diff --git a/Profile/Profile/Presentation/Settings/ManageAccountView.swift b/Profile/Profile/Presentation/Settings/ManageAccountView.swift index f4a38ab34..7f9475f76 100644 --- a/Profile/Profile/Presentation/Settings/ManageAccountView.swift +++ b/Profile/Profile/Presentation/Settings/ManageAccountView.swift @@ -182,7 +182,7 @@ public struct ManageAccountView: View { } ) }, - color: .clear, + color: Theme.Colors.background, textColor: Theme.Colors.accentColor, borderColor: Theme.Colors.accentColor ).padding(.horizontal, 24) diff --git a/Theme/Theme/Assets.xcassets/Colors/CourseCardBackground.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/CourseCardBackground.colorset/Contents.json new file mode 100644 index 000000000..8fef18d07 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/CourseCardBackground.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" : "0.184", + "green" : "0.129", + "red" : "0.098" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/Colors/SecondaryButton/Contents.json b/Theme/Theme/Assets.xcassets/Colors/SecondaryButton/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/SecondaryButton/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/Colors/SecondaryButton/SecondaryButtonBGColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/SecondaryButton/SecondaryButtonBGColor.colorset/Contents.json new file mode 100644 index 000000000..8fef18d07 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/SecondaryButton/SecondaryButtonBGColor.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" : "0.184", + "green" : "0.129", + "red" : "0.098" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/Colors/SecondaryButtonBorderColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/SecondaryButton/SecondaryButtonBorderColor.colorset/Contents.json similarity index 100% rename from Theme/Theme/Assets.xcassets/Colors/SecondaryButtonBorderColor.colorset/Contents.json rename to Theme/Theme/Assets.xcassets/Colors/SecondaryButton/SecondaryButtonBorderColor.colorset/Contents.json diff --git a/Theme/Theme/Assets.xcassets/Colors/SecondaryButtonTextColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/SecondaryButton/SecondaryButtonTextColor.colorset/Contents.json similarity index 100% rename from Theme/Theme/Assets.xcassets/Colors/SecondaryButtonTextColor.colorset/Contents.json rename to Theme/Theme/Assets.xcassets/Colors/SecondaryButton/SecondaryButtonTextColor.colorset/Contents.json diff --git a/Theme/Theme/SwiftGen/ThemeAssets.swift b/Theme/Theme/SwiftGen/ThemeAssets.swift index 2daf3d16b..5a9ed5fe8 100644 --- a/Theme/Theme/SwiftGen/ThemeAssets.swift +++ b/Theme/Theme/SwiftGen/ThemeAssets.swift @@ -35,6 +35,7 @@ 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 courseCardBackground = ColorAsset(name: "CourseCardBackground") public static let courseCardShadow = ColorAsset(name: "CourseCardShadow") public static let datesSectionBackground = ColorAsset(name: "DatesSectionBackground") public static let datesSectionStroke = ColorAsset(name: "DatesSectionStroke") @@ -54,6 +55,7 @@ public enum ThemeAssets { public static let progressDone = ColorAsset(name: "ProgressDone") public static let progressSkip = ColorAsset(name: "ProgressSkip") public static let selectedAndDone = ColorAsset(name: "SelectedAndDone") + public static let secondaryButtonBGColor = ColorAsset(name: "SecondaryButtonBGColor") public static let secondaryButtonBorderColor = ColorAsset(name: "SecondaryButtonBorderColor") public static let secondaryButtonTextColor = ColorAsset(name: "SecondaryButtonTextColor") public static let shadowColor = ColorAsset(name: "ShadowColor") diff --git a/Theme/Theme/Theme.swift b/Theme/Theme/Theme.swift index 73a4b4939..50fa75878 100644 --- a/Theme/Theme/Theme.swift +++ b/Theme/Theme/Theme.swift @@ -57,6 +57,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 secondaryButtonBGColor = ThemeAssets.secondaryButtonBGColor.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 @@ -71,6 +72,7 @@ public struct Theme { public private(set) static var primaryHeaderColor = ThemeAssets.primaryHeaderColor.swiftUIColor public private(set) static var secondaryHeaderColor = ThemeAssets.secondaryHeaderColor.swiftUIColor public private(set) static var courseCardShadow = ThemeAssets.courseCardShadow.swiftUIColor + public private(set) static var courseCardBackground = ThemeAssets.courseCardBackground.swiftUIColor public static func update( accentColor: Color = ThemeAssets.accentColor.swiftUIColor, diff --git a/WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift b/WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift index 640aa5545..d439058ba 100644 --- a/WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift +++ b/WhatsNew/WhatsNew/Presentation/Elements/WhatsNewNavigationButton.swift @@ -50,7 +50,7 @@ struct WhatsNewNavigationButton: View { Theme.Shapes.buttonShape .fill( type == .previous - ? Theme.Colors.background + ? Theme.Colors.secondaryButtonBGColor : Theme.Colors.accentButtonColor ) ) From 1e5ab71f3e69d979ea7f3a321fc0edae599f2baf Mon Sep 17 00:00:00 2001 From: Vadim Kuznetsov Date: Tue, 9 Jul 2024 16:39:20 +0300 Subject: [PATCH 28/55] fix: fixed header for SE device (#482) --- .../Extensions/UIApplicationExtension.swift | 17 +++++++++++++---- Core/Core/View/Base/DynamicOffsetView.swift | 8 +++++++- .../Container/CourseContainerView.swift | 10 +++++++++- 3 files changed, 29 insertions(+), 6 deletions(-) diff --git a/Core/Core/Extensions/UIApplicationExtension.swift b/Core/Core/Extensions/UIApplicationExtension.swift index fe149414f..f95bff70b 100644 --- a/Core/Core/Extensions/UIApplicationExtension.swift +++ b/Core/Core/Extensions/UIApplicationExtension.swift @@ -8,17 +8,17 @@ import UIKit import Theme -extension UIApplication { +public extension UIApplication { - public var keyWindow: UIWindow? { + var keyWindow: UIWindow? { UIApplication.shared.windows.first { $0.isKeyWindow } } - public func endEditing(force: Bool = true) { + func endEditing(force: Bool = true) { windows.forEach { $0.endEditing(force) } } - public class func topViewController( + class func topViewController( controller: UIViewController? = UIApplication.shared.keyWindow?.rootViewController ) -> UIViewController? { if let navigationController = controller as? UINavigationController { @@ -34,6 +34,15 @@ extension UIApplication { } return controller } + + var windowInsets: UIEdgeInsets { + guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = windowScene.windows.first else { + return .zero + } + + return window.safeAreaInsets + } } extension UINavigationController { diff --git a/Core/Core/View/Base/DynamicOffsetView.swift b/Core/Core/View/Base/DynamicOffsetView.swift index 59bedd83d..2c4d47fe1 100644 --- a/Core/Core/View/Base/DynamicOffsetView.swift +++ b/Core/Core/View/Base/DynamicOffsetView.swift @@ -12,7 +12,13 @@ public struct DynamicOffsetView: View { private let padHeight: CGFloat = 290 private let collapsedHorizontalHeight: CGFloat = 120 private let collapsedVerticalHeight: CGFloat = 100 - private let expandedHeight: CGFloat = 240 + private var expandedHeight: CGFloat { + let topInset = UIApplication.shared.windowInsets.top + guard topInset > 0 else { + return 240 + } + return 300 - topInset + } private let coordinateBoundaryLower: CGFloat = -115 private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index bc70c08f3..18964fae5 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -29,7 +29,15 @@ public struct CourseContainerView: View { private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } private let coordinateBoundaryLower: CGFloat = -115 - private let coordinateBoundaryHigher: CGFloat = 40 + + private var coordinateBoundaryHigher: CGFloat { + let topInset = UIApplication.shared.windowInsets.top + guard topInset > 0 else { + return 40 + } + + return topInset + } private struct GeometryName { static let backButton = "backButton" From 9c18b3085b855b5568afb86a5493457545fcad86 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Thu, 18 Jul 2024 10:25:41 +0300 Subject: [PATCH 29/55] feat: [FC-0047] Calendar synchronization (#466) * feat: calendar synchronization flow * fix: address feedback * fix: address feedback * fix: address feedback * fix: adress feedback * fix: address feedback * fix: address feedback --- Core/Core.xcodeproj/project.pbxproj | 24 + .../Core}/Data/Model/Data_CourseDates.swift | 31 +- .../Data/Model/Data_EnrollmentsStatus.swift | 31 ++ .../Core}/Domain/Model/CourseDates.swift | 133 +++-- Core/Core/Domain/Model/CourseForSync.swift | 43 ++ Core/Core/Domain/Model/SyncStatus.swift | 14 + Core/Core/SwiftGen/Strings.swift | 40 ++ .../View/Base/CalendarManagerProtocol.swift | 36 ++ Core/Core/en.lproj/Localizable.strings | 19 + Core/Core/uk.lproj/Localizable.strings | 19 +- Course/Course.xcodeproj/project.pbxproj | 24 +- Course/Course/Managers/CalendarManager.swift | 480 ------------------ .../Container/CourseContainerView.swift | 9 +- Course/Course/Presentation/CourseRouter.swift | 4 + .../Presentation/Dates/CourseDatesView.swift | 51 +- .../Dates/CourseDatesViewModel.swift | 273 +--------- .../Outline/CourseOutlineView.swift | 26 - .../Subviews/CalendarSyncStatusView.swift | 71 +++ .../Subviews/CustomDisclosureGroup.swift | 2 +- .../DatesSuccessView/DatesSuccessView.swift | 7 +- .../VideoDownloadQualityBarView.swift | 1 - Course/Course/SwiftGen/Strings.swift | 38 +- Course/Course/en.lproj/Localizable.strings | 18 +- Course/Course/uk.lproj/Localizable.strings | 18 +- .../Unit/CourseDateViewModelTests.swift | 9 +- .../DashboardCoreModel.xcdatamodel/contents | 10 +- OpenEdX.xcodeproj/project.pbxproj | 4 + OpenEdX/DI/AppAssembly.swift | 9 + OpenEdX/DI/ScreenAssembly.swift | 20 +- OpenEdX/Data/AppStorage.swift | 96 +++- OpenEdX/Data/DashboardPersistence.swift | 10 +- OpenEdX/Data/DatabaseManager.swift | 4 +- OpenEdX/Data/ProfilePersistence.swift | 168 ++++++ OpenEdX/Router.swift | 10 +- OpenEdX/View/MainScreenView.swift | 1 + OpenEdX/View/MainScreenViewModel.swift | 97 +++- Profile/Data/ProfileStorage.swift | 22 - Profile/Profile.xcodeproj/project.pbxproj | 63 ++- .../Data/Network/ProfileEndpoint.swift | 18 +- .../ProfileCoreModel.xcdatamodel/contents | 21 + .../ProfilePersistenceProtocol.swift | 39 ++ Profile/Profile/Data/ProfileRepository.swift | 48 ++ Profile/Profile/Data/ProfileStorage.swift | 35 ++ .../Profile/Domain/ProfileInteractor.swift | 10 + .../DatesAndCalendar/CalendarManager.swift | 415 +++++++++++++++ .../DatesAndCalendar/CoursesToSyncView.swift | 86 +++- .../DatesAndCalendarView.swift | 53 +- .../DatesAndCalendarViewModel.swift | 459 ++++++++++++++--- .../Elements/AssignmentStatusView.swift | 17 +- .../Elements/CalendarDialogView.swift | 12 +- .../Elements/DropDownPicker.swift | 71 ++- .../Elements/NewCalendarView.swift | 39 +- .../Models/CalendarSettings.swift | 56 ++ .../Models/CourseCalendarEvent.swift | 18 + .../Models/CourseCalendarState.swift | 18 + .../SyncCalendarOptionsView.swift | 88 +++- Profile/Profile/SwiftGen/Strings.swift | 22 +- Profile/Profile/en.lproj/Localizable.strings | 10 +- Profile/Profile/uk.lproj/Localizable.strings | 7 +- .../ProfileTests/ProfileMock.generated.swift | 79 +++ 60 files changed, 2365 insertions(+), 1191 deletions(-) rename {Course/Course => Core/Core}/Data/Model/Data_CourseDates.swift (87%) create mode 100644 Core/Core/Data/Model/Data_EnrollmentsStatus.swift rename {Course/Course => Core/Core}/Domain/Model/CourseDates.swift (70%) create mode 100644 Core/Core/Domain/Model/CourseForSync.swift create mode 100644 Core/Core/Domain/Model/SyncStatus.swift create mode 100644 Core/Core/View/Base/CalendarManagerProtocol.swift delete mode 100644 Course/Course/Managers/CalendarManager.swift create mode 100644 Course/Course/Presentation/Subviews/CalendarSyncStatusView.swift create mode 100644 OpenEdX/Data/ProfilePersistence.swift delete mode 100644 Profile/Data/ProfileStorage.swift create mode 100644 Profile/Profile/Data/Persistence/ProfileCoreModel.xcdatamodeld/ProfileCoreModel.xcdatamodel/contents create mode 100644 Profile/Profile/Data/Persistence/ProfilePersistenceProtocol.swift create mode 100644 Profile/Profile/Data/ProfileStorage.swift create mode 100644 Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift create mode 100644 Profile/Profile/Presentation/DatesAndCalendar/Models/CalendarSettings.swift create mode 100644 Profile/Profile/Presentation/DatesAndCalendar/Models/CourseCalendarEvent.swift create mode 100644 Profile/Profile/Presentation/DatesAndCalendar/Models/CourseCalendarState.swift diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index d52896f50..0b4400836 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -14,8 +14,10 @@ 021D924828DC860C00ACC565 /* Data_UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D924728DC860C00ACC565 /* Data_UserProfile.swift */; }; 021D925028DC89D100ACC565 /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D924F28DC89D100ACC565 /* UserProfile.swift */; }; 021D925728DCF12900ACC565 /* AlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D925628DCF12900ACC565 /* AlertView.swift */; }; + 022020462C11BB2200D15795 /* Data_CourseDates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022020452C11BB2200D15795 /* Data_CourseDates.swift */; }; 02280F5B294B4E6F0032823A /* Connectivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02280F5A294B4E6F0032823A /* Connectivity.swift */; }; 02284C182A3B1AE00007117F /* UIApplicationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02284C172A3B1AE00007117F /* UIApplicationExtension.swift */; }; + 02286D162C106393005EEC8D /* CourseDates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02286D152C106393005EEC8D /* CourseDates.swift */; }; 022C64E429AE0191000F532B /* TextWithUrls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022C64E329AE0191000F532B /* TextWithUrls.swift */; }; 0231CDBE2922422D00032416 /* CSSInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0231CDBD2922422D00032416 /* CSSInjector.swift */; }; 0233D56F2AF13EB200BAC8BD /* StarRatingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0233D56E2AF13EB200BAC8BD /* StarRatingView.swift */; }; @@ -74,6 +76,7 @@ 02A4833829B8A8F900D33F33 /* CoreDataModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833629B8A8F800D33F33 /* CoreDataModel.xcdatamodeld */; }; 02A4833A29B8A9AB00D33F33 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833929B8A9AB00D33F33 /* DownloadManager.swift */; }; 02A4833C29B8C57800D33F33 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833B29B8C57800D33F33 /* DownloadView.swift */; }; + 02A8C5812C05DBB4004B91FF /* Data_EnrollmentsStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A8C5802C05DBB4004B91FF /* Data_EnrollmentsStatus.swift */; }; 02AFCC182AEFDB24000360F0 /* ThirdPartyMailClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AFCC172AEFDB24000360F0 /* ThirdPartyMailClient.swift */; }; 02AFCC1A2AEFDC18000360F0 /* ThirdPartyMailer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AFCC192AEFDC18000360F0 /* ThirdPartyMailer.swift */; }; 02B2B594295C5C7A00914876 /* Thread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B2B593295C5C7A00914876 /* Thread.swift */; }; @@ -87,6 +90,9 @@ 02E224DB2BB76B3E00EF1ADB /* DynamicOffsetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E224DA2BB76B3E00EF1ADB /* DynamicOffsetView.swift */; }; 02E225B0291D29EB0067769A /* UrlExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E225AF291D29EB0067769A /* UrlExtension.swift */; }; 02E93F852AEBAEBC006C4750 /* AppReviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E93F842AEBAEBC006C4750 /* AppReviewViewModel.swift */; }; + 02EBC7572C19DCDB00BE182C /* SyncStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EBC7562C19DCDB00BE182C /* SyncStatus.swift */; }; + 02EBC7592C19DE1100BE182C /* CalendarManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EBC7582C19DE1100BE182C /* CalendarManagerProtocol.swift */; }; + 02EBC75B2C19DE3D00BE182C /* CourseForSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EBC75A2C19DE3D00BE182C /* CourseForSync.swift */; }; 02F164372902A9EB0090DDEF /* StringExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F164362902A9EB0090DDEF /* StringExtension.swift */; }; 02F6EF3B28D9B8EC00835477 /* CourseCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F6EF3A28D9B8EC00835477 /* CourseCellView.swift */; }; 02F6EF4A28D9F0A700835477 /* DateExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F6EF4928D9F0A700835477 /* DateExtension.swift */; }; @@ -205,8 +211,10 @@ 021D924728DC860C00ACC565 /* Data_UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_UserProfile.swift; sourceTree = ""; }; 021D924F28DC89D100ACC565 /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; 021D925628DCF12900ACC565 /* AlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertView.swift; sourceTree = ""; }; + 022020452C11BB2200D15795 /* Data_CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_CourseDates.swift; sourceTree = ""; }; 02280F5A294B4E6F0032823A /* Connectivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.swift; sourceTree = ""; }; 02284C172A3B1AE00007117F /* UIApplicationExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationExtension.swift; sourceTree = ""; }; + 02286D152C106393005EEC8D /* CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDates.swift; sourceTree = ""; }; 022C64E329AE0191000F532B /* TextWithUrls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextWithUrls.swift; sourceTree = ""; }; 0231CDBD2922422D00032416 /* CSSInjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSSInjector.swift; sourceTree = ""; }; 0233D56E2AF13EB200BAC8BD /* StarRatingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StarRatingView.swift; sourceTree = ""; }; @@ -264,6 +272,7 @@ 02A4833729B8A8F800D33F33 /* CoreDataModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CoreDataModel.xcdatamodel; sourceTree = ""; }; 02A4833929B8A9AB00D33F33 /* DownloadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManager.swift; sourceTree = ""; }; 02A4833B29B8C57800D33F33 /* DownloadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadView.swift; sourceTree = ""; }; + 02A8C5802C05DBB4004B91FF /* Data_EnrollmentsStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_EnrollmentsStatus.swift; sourceTree = ""; }; 02AFCC172AEFDB24000360F0 /* ThirdPartyMailClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThirdPartyMailClient.swift; sourceTree = ""; }; 02AFCC192AEFDC18000360F0 /* ThirdPartyMailer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThirdPartyMailer.swift; sourceTree = ""; }; 02B2B593295C5C7A00914876 /* Thread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thread.swift; sourceTree = ""; }; @@ -277,6 +286,9 @@ 02E224DA2BB76B3E00EF1ADB /* DynamicOffsetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicOffsetView.swift; sourceTree = ""; }; 02E225AF291D29EB0067769A /* UrlExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UrlExtension.swift; sourceTree = ""; }; 02E93F842AEBAEBC006C4750 /* AppReviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReviewViewModel.swift; sourceTree = ""; }; + 02EBC7562C19DCDB00BE182C /* SyncStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatus.swift; sourceTree = ""; }; + 02EBC7582C19DE1100BE182C /* CalendarManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarManagerProtocol.swift; sourceTree = ""; }; + 02EBC75A2C19DE3D00BE182C /* CourseForSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseForSync.swift; sourceTree = ""; }; 02ED50CB29A64B84008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02F164362902A9EB0090DDEF /* StringExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StringExtension.swift; sourceTree = ""; }; 02F6EF3A28D9B8EC00835477 /* CourseCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseCellView.swift; sourceTree = ""; }; @@ -611,6 +623,8 @@ 027BD3912907D88F00392132 /* Data_RegistrationFields.swift */, 028F9F36293A44C700DE65D0 /* Data_ResetPassword.swift */, 07E0939E2B308D2800F1E4B2 /* Data_Certificate.swift */, + 02A8C5802C05DBB4004B91FF /* Data_EnrollmentsStatus.swift */, + 022020452C11BB2200D15795 /* Data_CourseDates.swift */, ); path = Model; sourceTree = ""; @@ -637,7 +651,10 @@ 027BD39B2908810C00392132 /* RegisterUser.swift */, 028F9F38293A452B00DE65D0 /* ResetPassword.swift */, 020C31C8290AC3F700D6DEA2 /* PickerFields.swift */, + 02EBC75A2C19DE3D00BE182C /* CourseForSync.swift */, 076F297E2A1F80C800967E7D /* Pagination.swift */, + 02286D152C106393005EEC8D /* CourseDates.swift */, + 02EBC7562C19DCDB00BE182C /* SyncStatus.swift */, ); path = Model; sourceTree = ""; @@ -735,6 +752,7 @@ 025A204F2C071EB0003EA08D /* ErrorAlertView.swift */, 0727878028D25EFD002E9142 /* SnackBarView.swift */, 02F6EF3A28D9B8EC00835477 /* CourseCellView.swift */, + 02EBC7582C19DE1100BE182C /* CalendarManagerProtocol.swift */, 024FCCFF28EF1CD300232339 /* WebBrowser.swift */, 028CE96829858ECC00B6B1C3 /* FlexibleKeyboardInputView.swift */, 023A4DD3299E66BD006C0E48 /* OfflineSnackBarView.swift */, @@ -1089,6 +1107,7 @@ 0727878528D31657002E9142 /* Data_User.swift in Sources */, 064987942B4D69FF0071642A /* WebviewInjection.swift in Sources */, 02F6EF4A28D9F0A700835477 /* DateExtension.swift in Sources */, + 02EBC7572C19DCDB00BE182C /* SyncStatus.swift in Sources */, DBF6F2462B01DAFE0098414B /* AgreementConfig.swift in Sources */, 027BD3AF2909475000392132 /* DismissKeyboardTapHandler.swift in Sources */, 027F1BF72C071C820001A24C /* NavigationTitle.swift in Sources */, @@ -1102,6 +1121,7 @@ 027BD3B92909476200392132 /* KeyboardAvoidingModifier.swift in Sources */, BA4AFB422B5A7A0900A21367 /* VideoDownloadQualityView.swift in Sources */, 0770DE2C28D092B3006D8A5D /* NetworkLogger.swift in Sources */, + 02A8C5812C05DBB4004B91FF /* Data_EnrollmentsStatus.swift in Sources */, 06BEEA0E2B6A55C500D25A97 /* ColorInversionInjection.swift in Sources */, 064987972B4D69FF0071642A /* WebView.swift in Sources */, 0770DE2A28D0929E006D8A5D /* HTTPTask.swift in Sources */, @@ -1159,8 +1179,10 @@ 02C917F029CDA99E00DBB8BD /* Data_Enrollments.swift in Sources */, 024FCD0028EF1CD300232339 /* WebBrowser.swift in Sources */, 027BD3B52909475900392132 /* KeyboardStateObserver.swift in Sources */, + 022020462C11BB2200D15795 /* Data_CourseDates.swift in Sources */, 0283347D28D4D3DE00C828FC /* Data_Discovery.swift in Sources */, 064987962B4D69FF0071642A /* WebviewMessage.swift in Sources */, + 02EBC75B2C19DE3D00BE182C /* CourseForSync.swift in Sources */, 07DDFCBD29A780BB00572595 /* UINavigationController+Animation.swift in Sources */, 023A4DD4299E66BD006C0E48 /* OfflineSnackBarView.swift in Sources */, 021D925728DCF12900ACC565 /* AlertView.swift in Sources */, @@ -1190,6 +1212,7 @@ 071009D028D1E3A600344290 /* Constants.swift in Sources */, 0770DE1928D0847D006D8A5D /* BaseRouter.swift in Sources */, BA30427F2B20B320009B64B7 /* SocialAuthError.swift in Sources */, + 02286D162C106393005EEC8D /* CourseDates.swift in Sources */, 0284DBFE28D48C5300830893 /* CourseItem.swift in Sources */, 0248C92329C075EF00DC8402 /* CourseBlockModel.swift in Sources */, E0D5861A2B2FF74C009B4BA7 /* DiscoveryConfig.swift in Sources */, @@ -1203,6 +1226,7 @@ BAFB99842B0E282E007D09F9 /* MicrosoftConfig.swift in Sources */, 02B2B594295C5C7A00914876 /* Thread.swift in Sources */, E0D5861C2B2FF85B009B4BA7 /* RawStringExtactable.swift in Sources */, + 02EBC7592C19DE1100BE182C /* CalendarManagerProtocol.swift in Sources */, 027BD3BD2909478B00392132 /* UIView+EnclosingScrollView.swift in Sources */, BA8FA6682AD59A5700EA029A /* SocialAuthButton.swift in Sources */, 02D400612B0678190029D168 /* SKStoreReviewControllerExtension.swift in Sources */, diff --git a/Course/Course/Data/Model/Data_CourseDates.swift b/Core/Core/Data/Model/Data_CourseDates.swift similarity index 87% rename from Course/Course/Data/Model/Data_CourseDates.swift rename to Core/Core/Data/Model/Data_CourseDates.swift index 2ef5d339b..35616b020 100644 --- a/Course/Course/Data/Model/Data_CourseDates.swift +++ b/Core/Core/Data/Model/Data_CourseDates.swift @@ -1,12 +1,11 @@ // // Data_CourseDates.swift -// Course +// Core // -// Created by Muhammad Umer on 10/18/23. +// Created by  Stepanok Ivan on 05.06.2024. // import Foundation -import Core public extension DataLayer { struct CourseDates: Codable { @@ -100,46 +99,46 @@ public extension DataLayer { case upgradeToResetBanner case resetDatesBanner - var header: String { + public var header: String { switch self { case .datesTabInfoBanner: - CourseLocalization.CourseDates.ResetDate.TabInfoBanner.header + CoreLocalization.CourseDates.ResetDate.TabInfoBanner.header case .upgradeToCompleteGradedBanner: - CourseLocalization.CourseDates.ResetDate.UpgradeToCompleteGradedBanner.header + CoreLocalization.CourseDates.ResetDate.UpgradeToCompleteGradedBanner.header case .upgradeToResetBanner: - CourseLocalization.CourseDates.ResetDate.UpgradeToResetBanner.header + CoreLocalization.CourseDates.ResetDate.UpgradeToResetBanner.header case .resetDatesBanner: - CourseLocalization.CourseDates.ResetDate.ResetDateBanner.header + CoreLocalization.CourseDates.ResetDate.ResetDateBanner.header } } - var body: String { + public var body: String { switch self { case .datesTabInfoBanner: - CourseLocalization.CourseDates.ResetDate.TabInfoBanner.body + CoreLocalization.CourseDates.ResetDate.TabInfoBanner.body case .upgradeToCompleteGradedBanner: - CourseLocalization.CourseDates.ResetDate.UpgradeToCompleteGradedBanner.body + CoreLocalization.CourseDates.ResetDate.UpgradeToCompleteGradedBanner.body case .upgradeToResetBanner: - CourseLocalization.CourseDates.ResetDate.UpgradeToResetBanner.body + CoreLocalization.CourseDates.ResetDate.UpgradeToResetBanner.body case .resetDatesBanner: - CourseLocalization.CourseDates.ResetDate.ResetDateBanner.body + CoreLocalization.CourseDates.ResetDate.ResetDateBanner.body } } - var buttonTitle: String { + public 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 + CoreLocalization.CourseDates.ResetDate.ResetDateBanner.button default: "" } } - var analyticsBannerType: String { + public var analyticsBannerType: String { switch self { case .datesTabInfoBanner: "info" diff --git a/Core/Core/Data/Model/Data_EnrollmentsStatus.swift b/Core/Core/Data/Model/Data_EnrollmentsStatus.swift new file mode 100644 index 000000000..df5acf0d2 --- /dev/null +++ b/Core/Core/Data/Model/Data_EnrollmentsStatus.swift @@ -0,0 +1,31 @@ +// +// Data_EnrollmentsStatus.swift +// Core +// +// Created by  Stepanok Ivan on 28.05.2024. +// + +import Foundation + +extension DataLayer { + // MARK: - EnrollmentsStatusElement + public struct EnrollmentsStatusElement: Codable { + public let courseID: String? + public let courseName: String? + public let isActive: Bool? + + public enum CodingKeys: String, CodingKey { + case courseID = "course_id" + case courseName = "course_name" + case isActive = "is_active" + } + + public init(courseID: String?, courseName: String?, isActive: Bool?) { + self.courseID = courseID + self.courseName = courseName + self.isActive = isActive + } + } + + public typealias EnrollmentsStatus = [EnrollmentsStatusElement] +} diff --git a/Course/Course/Domain/Model/CourseDates.swift b/Core/Core/Domain/Model/CourseDates.swift similarity index 70% rename from Course/Course/Domain/Model/CourseDates.swift rename to Core/Core/Domain/Model/CourseDates.swift index 966899cb9..5b1c6436c 100644 --- a/Course/Course/Domain/Model/CourseDates.swift +++ b/Core/Core/Domain/Model/CourseDates.swift @@ -1,20 +1,20 @@ // // CourseDates.swift -// Course +// Core // -// Created by Muhammad Umer on 10/18/23. +// Created by  Stepanok Ivan on 05.06.2024. // import Foundation -import Core +import CryptoKit public struct CourseDates { - let datesBannerInfo: DatesBannerInfo - let courseDateBlocks: [CourseDateBlock] - let hasEnded, learnerIsFullAccess: Bool - let userTimezone: String? + public let datesBannerInfo: DatesBannerInfo + public let courseDateBlocks: [CourseDateBlock] + public let hasEnded, learnerIsFullAccess: Bool + public let userTimezone: String? - var statusDatesBlocks: [CompletionStatus: [Date: [CourseDateBlock]]] { + public var statusDatesBlocks: [CompletionStatus: [Date: [CourseDateBlock]]] { var statusDatesBlocks: [CompletionStatus: [Date: [CourseDateBlock]]] = [:] var statusBlocks: [CompletionStatus: [CourseDateBlock]] = [:] @@ -56,15 +56,41 @@ public struct CourseDates { return statusDatesBlocks } - var dateBlocks: [Date: [CourseDateBlock]] { + public var dateBlocks: [Date: [CourseDateBlock]] { return courseDateBlocks.reduce(into: [:]) { result, block in let date = block.date result[date, default: []].append(block) } } + + public init( + datesBannerInfo: DatesBannerInfo, + courseDateBlocks: [CourseDateBlock], + hasEnded: Bool, + learnerIsFullAccess: Bool, + userTimezone: String? + ) { + self.datesBannerInfo = datesBannerInfo + self.courseDateBlocks = courseDateBlocks + self.hasEnded = hasEnded + self.learnerIsFullAccess = learnerIsFullAccess + self.userTimezone = userTimezone + } + + public var checksum: String { + var combinedString = "" + for block in self.courseDateBlocks { + let assignmentType = block.assignmentType ?? "" + combinedString += assignmentType + block.firstComponentBlockID + block.date.description + } + + let checksumData = SHA256.hash(data: Data(combinedString.utf8)) + let checksumString = checksumData.map { String(format: "%02hhx", $0) }.joined() + return checksumString + } } -extension Date { +public extension Date { static var today: Date { return Calendar.current.startOfDay(for: Date()) } @@ -120,26 +146,26 @@ extension Date { public struct CourseDateBlock: Identifiable { public let id: UUID = UUID() - let assignmentType: String? - let complete: Bool? - let date: Date - let dateType, description: String - let learnerHasAccess: Bool - let link: String - let linkText: String? - let title: String - let extraInfo: String? - let firstComponentBlockID: String - - var formattedDate: String { + public let assignmentType: String? + public let complete: Bool? + public let date: Date + public let dateType, description: String + public let learnerHasAccess: Bool + public let link: String + public let linkText: String? + public let title: String + public let extraInfo: String? + public let firstComponentBlockID: String + + public var formattedDate: String { return date.dateToString(style: .shortWeekdayMonthDayYear) } - var isInPast: Bool { + public var isInPast: Bool { return date.isInPast } - var isToday: Bool { + public var isToday: Bool { if dateType.isEmpty { return true } else { @@ -147,55 +173,55 @@ public struct CourseDateBlock: Identifiable { } } - var isInFuture: Bool { + public var isInFuture: Bool { return date.isInFuture } - var isThisWeek: Bool { + public var isThisWeek: Bool { return date.isThisWeek } - var isNextWeek: Bool { + public var isNextWeek: Bool { return date.isNextWeek } - var isUpcoming: Bool { + public var isUpcoming: Bool { return date.isUpcoming } - var isAssignment: Bool { + public var isAssignment: Bool { return BlockStatus.status(of: dateType) == .assignment } - var isVerifiedOnly: Bool { + public var isVerifiedOnly: Bool { return !learnerHasAccess } - var isComplete: Bool { + public var isComplete: Bool { return complete ?? false } - var isLearnerAssignment: Bool { + public var isLearnerAssignment: Bool { return learnerHasAccess && isAssignment } - var isPastDue: Bool { + public var isPastDue: Bool { return !isComplete && (date < .today) } - var isUnreleased: Bool { + public var isUnreleased: Bool { return link.isEmpty } - var canShowLink: Bool { + public var canShowLink: Bool { return !isUnreleased && isLearnerAssignment } - var isAvailable: Bool { + public var isAvailable: Bool { return learnerHasAccess && (!isUnreleased || !isLearnerAssignment) } - var blockStatus: BlockStatus { + public var blockStatus: BlockStatus { if isComplete { return .completed } @@ -215,7 +241,7 @@ public struct CourseDateBlock: Identifiable { return BlockStatus.status(of: dateType) } - var blockImage: ImageAsset? { + public var blockImage: ImageAsset? { if !learnerHasAccess { return CoreAssets.lockIcon } @@ -240,14 +266,33 @@ public struct CourseDateBlock: Identifiable { } public struct DatesBannerInfo { - let missedDeadlines, contentTypeGatingEnabled, missedGatedContent: Bool - let verifiedUpgradeLink: String? - let status: DataLayer.BannerInfoStatus? + public let missedDeadlines, contentTypeGatingEnabled, missedGatedContent: Bool + public let verifiedUpgradeLink: String? + public let status: DataLayer.BannerInfoStatus? + + public init( + missedDeadlines: Bool, + contentTypeGatingEnabled: Bool, + missedGatedContent: Bool, + verifiedUpgradeLink: String?, + status: DataLayer.BannerInfoStatus? + ) { + self.missedDeadlines = missedDeadlines + self.contentTypeGatingEnabled = contentTypeGatingEnabled + self.missedGatedContent = missedGatedContent + self.verifiedUpgradeLink = verifiedUpgradeLink + self.status = status + } } public struct CourseDateBanner { - let datesBannerInfo: DatesBannerInfo - let hasEnded: Bool + public let datesBannerInfo: DatesBannerInfo + public let hasEnded: Bool + + public init(datesBannerInfo: DatesBannerInfo, hasEnded: Bool) { + self.datesBannerInfo = datesBannerInfo + self.hasEnded = hasEnded + } } public enum BlockStatus { @@ -288,7 +333,7 @@ public enum CompletionStatus: String { case upcoming = "Upcoming" } -extension Array { +public extension Array { mutating func modifyForEach(_ body: (_ element: inout Element) -> Void) { for index in indices { modifyElement(atIndex: index) { body(&$0) } diff --git a/Core/Core/Domain/Model/CourseForSync.swift b/Core/Core/Domain/Model/CourseForSync.swift new file mode 100644 index 000000000..938c6efa2 --- /dev/null +++ b/Core/Core/Domain/Model/CourseForSync.swift @@ -0,0 +1,43 @@ +// +// CourseForSync.swift +// Core +// +// Created by  Stepanok Ivan on 12.06.2024. +// + +import Foundation + +// MARK: - CourseForSync +public struct CourseForSync: Identifiable { + public let id: UUID + public let courseID: String + public let name: String + public var synced: Bool + public var active: Bool + + public init(id: UUID = UUID(), courseID: String, name: String, synced: Bool, active: Bool) { + self.id = id + self.courseID = courseID + self.name = name + self.synced = synced + self.active = active + } +} + +extension DataLayer.EnrollmentsStatus { + public var domain: [CourseForSync] { + self.compactMap { + guard let courseID = $0.courseID, + let courseName = $0.courseName, + let isActive = $0.isActive else { return nil } + return CourseForSync( + id: UUID(), + courseID: courseID, + name: courseName, + synced: false, + active: isActive + ) + } + } +} + diff --git a/Core/Core/Domain/Model/SyncStatus.swift b/Core/Core/Domain/Model/SyncStatus.swift new file mode 100644 index 000000000..a32d0cc4f --- /dev/null +++ b/Core/Core/Domain/Model/SyncStatus.swift @@ -0,0 +1,14 @@ +// +// SyncStatus.swift +// Core +// +// Created by  Stepanok Ivan on 12.06.2024. +// + +import Foundation + +public enum SyncStatus { + case synced + case failed + case offline +} diff --git a/Core/Core/SwiftGen/Strings.swift b/Core/Core/SwiftGen/Strings.swift index 4bd41f9eb..8cdf97b6e 100644 --- a/Core/Core/SwiftGen/Strings.swift +++ b/Core/Core/SwiftGen/Strings.swift @@ -78,6 +78,46 @@ public enum CoreLocalization { return CoreLocalization.tr("Localizable", "COURSEWARE.SECTION_COMPLETED", String(describing: p1), fallback: "You've completed “%@”.") } } + public enum CourseDates { + public enum ResetDate { + /// Your dates could not be shifted. Please try again. + public static let errorMessage = CoreLocalization.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 = CoreLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.SUCCESS_MESSAGE", fallback: "Your dates have been successfully shifted.") + /// Course Dates + public static let title = CoreLocalization.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 = CoreLocalization.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 = CoreLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BUTTON", fallback: "Shift due dates") + /// Missed some deadlines? + public static let header = CoreLocalization.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 = CoreLocalization.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 = CoreLocalization.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 = CoreLocalization.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 = CoreLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BUTTON", fallback: "") + /// + public static let header = CoreLocalization.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 = CoreLocalization.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 = CoreLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON", fallback: "") + /// + public static let header = CoreLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.HEADER", fallback: "") + } + } + } public enum Date { /// Course Ended public static let courseEnded = CoreLocalization.tr("Localizable", "DATE.COURSE_ENDED", fallback: "Course Ended") diff --git a/Core/Core/View/Base/CalendarManagerProtocol.swift b/Core/Core/View/Base/CalendarManagerProtocol.swift new file mode 100644 index 000000000..ef7fdcb3e --- /dev/null +++ b/Core/Core/View/Base/CalendarManagerProtocol.swift @@ -0,0 +1,36 @@ +// +// CalendarManagerProtocol.swift +// Core +// +// Created by  Stepanok Ivan on 12.06.2024. +// + +import Foundation + +public protocol CalendarManagerProtocol { + func createCalendarIfNeeded() + func filterCoursesBySelected(fetchedCourses: [CourseForSync]) async -> [CourseForSync] + func removeOldCalendar() + func removeOutdatedEvents(courseID: String) async + func syncCourse(courseID: String, courseName: String, dates: CourseDates) async + func requestAccess() async -> Bool + func courseStatus(courseID: String) -> SyncStatus + func clearAllData(removeCalendar: Bool) + func isDatesChanged(courseID: String, checksum: String) -> Bool +} + +#if DEBUG +public struct CalendarManagerMock: CalendarManagerProtocol { + public func createCalendarIfNeeded() {} + public func filterCoursesBySelected(fetchedCourses: [CourseForSync]) async -> [CourseForSync] {[]} + public func removeOldCalendar() {} + public func removeOutdatedEvents(courseID: String) async {} + public func syncCourse(courseID: String, courseName: String, dates: CourseDates) async {} + public func requestAccess() async -> Bool { true } + public func courseStatus(courseID: String) -> SyncStatus { .synced } + public func clearAllData(removeCalendar: Bool) {} + public func isDatesChanged(courseID: String, checksum: String) -> Bool {false} + + public init() {} +} +#endif diff --git a/Core/Core/en.lproj/Localizable.strings b/Core/Core/en.lproj/Localizable.strings index b1fda17c5..b4ca1bc64 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -120,3 +120,22 @@ "YESTERDAY" = "Yesterday"; "OPEN_IN_BROWSER"="View in Safari"; + +"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/Core/Core/uk.lproj/Localizable.strings b/Core/Core/uk.lproj/Localizable.strings index 1026b82e0..f907e02f0 100644 --- a/Core/Core/uk.lproj/Localizable.strings +++ b/Core/Core/uk.lproj/Localizable.strings @@ -110,7 +110,18 @@ "SIGN_IN.LOG_IN_BTN" = "Увійти"; "REGISTER" = "Реєстрація"; -"TOMORROW" = "Tomorrow"; -"YESTERDAY" = "Yesterday"; - -"OPEN_IN_BROWSER"="View in Safari"; +"TOMORROW" = "Завтра"; +"YESTERDAY" = "Учора"; +"OPEN_IN_BROWSER"="Переглянути в Safari"; +"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BODY" = "Не хвилюйтеся - перенесіть наш запропонований розклад, щоб виконати прострочені завдання без втрати прогресу."; +"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BUTTON" = "Зміщення термінів виконання"; +"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.HEADER" = "Пропустив деякі терміни?"; +"COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.BODY" = "Ми склали запропонований розклад, щоб допомогти вам не відставати від курсу. Але не хвилюйтеся – він гнучкий, тож ви можете навчатися у своєму власному темпі. Якщо трапиться, що ви відстаєте, ви будете мати можливість коригувати дати, щоб тримати себе в курсі."; +"COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.HEADER" = ""; +"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BODY" = "Щоб виконати оцінені завдання в рамках цього курсу, ви можете оновити сьогодні."; "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" = "Ви перевіряєте цей курс, що означає, що ви не можете брати участь у оцінюваних завданнях. Схоже, що ви пропустили деякі важливі терміни згідно з нашим запропонованим розкладом. Щоб виконати оцінені завдання в рамках цей курс і перенести прострочені завдання в майбутнє, ви можете оновити сьогодні."; +"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON" = ""; +"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.HEADER" = ""; +"COURSE_DATES.RESET_DATE.ERROR_MESSAGE" = "Ваші дати не можуть бути зміщені. Спробуйте ще раз."; +"COURSE_DATES.RESET_DATE.SUCCESS_MESSAGE" = "Ваші дати успішно перенесено."; "COURSE_DATES.RESET_DATE.TITLE" = "Дати курсу"; diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index cc778b214..33c43eeaf 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -7,6 +7,7 @@ objects = { /* Begin PBXBuildFile section */ + 02197DC52C1AFC0600CC8FF2 /* CalendarSyncStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EBC7542C19CFCF00BE182C /* CalendarSyncStatusView.swift */; }; 02280F5E294B4FDA0032823A /* CourseCoreModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 02280F5C294B4FDA0032823A /* CourseCoreModel.xcdatamodeld */; }; 02280F60294B50030032823A /* CoursePersistenceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02280F5F294B50030032823A /* CoursePersistenceProtocol.swift */; }; 022C64D829ACEC48000F532B /* HandoutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022C64D729ACEC48000F532B /* HandoutsView.swift */; }; @@ -82,7 +83,6 @@ 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 */; }; @@ -95,8 +95,6 @@ DB205BFB2AE81B1200136EC2 /* CourseDateViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB205BFA2AE81B1200136EC2 /* CourseDateViewModelTests.swift */; }; DB7D6EAC2ADFCAC50036BB13 /* CourseDatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EAB2ADFCAC40036BB13 /* CourseDatesView.swift */; }; DB7D6EAE2ADFCB4A0036BB13 /* CourseDatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EAD2ADFCB4A0036BB13 /* CourseDatesViewModel.swift */; }; - DB7D6EB02ADFDA0E0036BB13 /* CourseDates.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EAF2ADFDA0E0036BB13 /* CourseDates.swift */; }; - DB7D6EB22ADFE9510036BB13 /* Data_CourseDates.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EB12ADFE9510036BB13 /* Data_CourseDates.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -155,6 +153,7 @@ 02C3553A2C08DCE000501342 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = uk; path = uk.lproj/Localizable.stringsdict; sourceTree = ""; }; 02D4FC2D2BBD7C9C00C47748 /* MessageSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSectionView.swift; sourceTree = ""; }; 02E3803D2BFF9F0A00815AFA /* CourseProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseProgressView.swift; sourceTree = ""; }; + 02EBC7542C19CFCF00BE182C /* CalendarSyncStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarSyncStatusView.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 = ""; }; @@ -199,7 +198,6 @@ 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 = ""; }; @@ -216,8 +214,6 @@ DB205BFA2AE81B1200136EC2 /* CourseDateViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDateViewModelTests.swift; sourceTree = ""; }; DB7D6EAB2ADFCAC40036BB13 /* CourseDatesView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CourseDatesView.swift; sourceTree = ""; }; DB7D6EAD2ADFCB4A0036BB13 /* CourseDatesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDatesViewModel.swift; sourceTree = ""; }; - DB7D6EAF2ADFDA0E0036BB13 /* CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDates.swift; sourceTree = ""; }; - DB7D6EB12ADFE9510036BB13 /* Data_CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_CourseDates.swift; sourceTree = ""; }; DBE05972CB5115D4535C6B8A /* Pods-App-Course-CourseTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Course-CourseTests.debug.xcconfig"; path = "Target Support Files/Pods-App-Course-CourseTests/Pods-App-Course-CourseTests.debug.xcconfig"; sourceTree = ""; }; E5E795BD160CDA7D9C120DE6 /* Pods_App_Course.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_Course.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E6BDAE887ED8A46860B3F6D3 /* Pods-App-Course-CourseTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Course-CourseTests.release.xcconfig"; path = "Target Support Files/Pods-App-Course-CourseTests/Pods-App-Course-CourseTests.release.xcconfig"; sourceTree = ""; }; @@ -313,6 +309,7 @@ 02B6B3B828E1D12900232911 /* Data */, 02B6B3B528E1D10700232911 /* Domain */, 02EAE2CA28E1F0A700529644 /* Presentation */, + 97CA95212B875EA200A9EDEA /* Views */, 97EA4D822B84EFA900663F58 /* Managers */, 02B6B3B428E1C49400232911 /* Localizable.strings */, 02C355372C08DCD700501342 /* Localizable.stringsdict */, @@ -365,7 +362,6 @@ 027020FB28E7362100F54332 /* Data_CourseOutlineResponse.swift */, 022C64DB29ACFDEE000F532B /* Data_HandoutsResponse.swift */, 022C64DF29ADEA9B000F532B /* Data_UpdatesResponse.swift */, - DB7D6EB12ADFE9510036BB13 /* Data_CourseDates.swift */, ); path = Model; sourceTree = ""; @@ -434,7 +430,6 @@ 02B6B3C228E1DCD100232911 /* CourseDetails.swift */, 0276D75C29DDA3F80004CDF8 /* ResumeBlock.swift */, 022C64E129ADEB83000F532B /* CourseUpdate.swift */, - DB7D6EAF2ADFDA0E0036BB13 /* CourseDates.swift */, ); path = Model; sourceTree = ""; @@ -544,14 +539,6 @@ path = DatesSuccessView; sourceTree = ""; }; - 97EA4D822B84EFA900663F58 /* Managers */ = { - isa = PBXGroup; - children = ( - 97EA4D852B85034D00663F58 /* CalendarManager.swift */, - ); - path = Managers; - sourceTree = ""; - }; BA58CF622B471047005B102E /* VideoDownloadQualityBarView */ = { isa = PBXGroup; children = ( @@ -600,6 +587,7 @@ 02FCB2B22BBEB36600373180 /* CourseHeaderView.swift */, 02BB20172BFCE7B200364948 /* CustomDisclosureGroup.swift */, 02E3803D2BFF9F0A00815AFA /* CourseProgressView.swift */, + 02EBC7542C19CFCF00BE182C /* CalendarSyncStatusView.swift */, ); path = Subviews; sourceTree = ""; @@ -880,7 +868,6 @@ 02280F60294B50030032823A /* CoursePersistenceProtocol.swift in Sources */, 02454CAA2A2619B40043052A /* LessonProgressView.swift in Sources */, 975F475E2B6151FD00E5B031 /* CourseDatesMock.swift in Sources */, - DB7D6EB22ADFE9510036BB13 /* Data_CourseDates.swift in Sources */, 975F47602B615DA700E5B031 /* CourseStructureMock.swift in Sources */, 02280F5E294B4FDA0032823A /* CourseCoreModel.xcdatamodeld in Sources */, 0766DFCE299AB26D00EBEF6A /* EncodedVideoPlayer.swift in Sources */, @@ -900,9 +887,9 @@ 067B7B4F2BED339200D1768F /* PlayerDelegateProtocol.swift in Sources */, 0231124D28EDA804002588FB /* CourseUnitView.swift in Sources */, 027020FC28E7362100F54332 /* Data_CourseOutlineResponse.swift in Sources */, - DB7D6EB02ADFDA0E0036BB13 /* CourseDates.swift in Sources */, 067B7B532BED339200D1768F /* PlayerServiceProtocol.swift in Sources */, BAD9CA2D2B2736BB00DE790A /* LessonLineProgressView.swift in Sources */, + 02197DC52C1AFC0600CC8FF2 /* CalendarSyncStatusView.swift in Sources */, 060E8BCA2B5FD68C0080C952 /* UnitStack.swift in Sources */, 0295C889299BBE8200ABE571 /* CourseNavigationView.swift in Sources */, 06FD7EDF2B1F29F3008D632B /* CourseVerticalImageView.swift in Sources */, @@ -931,7 +918,6 @@ 02E3803E2BFF9F0A00815AFA /* CourseProgressView.swift in Sources */, 022F8E162A1DFBC6008EFAB9 /* YouTubeVideoPlayerViewModel.swift in Sources */, 02B6B3BE28E1D15C00232911 /* CourseEndpoint.swift in Sources */, - 97EA4D862B85034D00663F58 /* CalendarManager.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Course/Course/Managers/CalendarManager.swift b/Course/Course/Managers/CalendarManager.swift deleted file mode 100644 index 118880aef..000000000 --- a/Course/Course/Managers/CalendarManager.swift +++ /dev/null @@ -1,480 +0,0 @@ -// -// 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 18964fae5..6ffa4cf88 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -144,7 +144,8 @@ public struct CourseContainerView: View { private func showDatesSuccessView(title: String, message: String) -> some View { return DatesSuccessView( title: title, - message: message + message: message, + selectedTab: .dates ) { courseDatesViewModel.resetEventState() } @@ -220,7 +221,8 @@ public struct CourseContainerView: View { courseID: courseID, coordinate: $coordinate, collapsed: $collapsed, - viewModel: courseDatesViewModel + viewModel: Container.shared.resolve(CourseDatesViewModel.self, + arguments: courseID, title)! ) .tabItem { tab.image @@ -357,7 +359,8 @@ struct CourseScreensView_Previews: PreviewProvider { config: ConfigMock(), courseID: "1", courseName: "a", - analytics: CourseAnalyticsMock() + analytics: CourseAnalyticsMock(), + calendarManager: CalendarManagerMock() ), courseID: "", title: "Title of Course" diff --git a/Course/Course/Presentation/CourseRouter.swift b/Course/Course/Presentation/CourseRouter.swift index 570b5a09c..f198f2fb5 100644 --- a/Course/Course/Presentation/CourseRouter.swift +++ b/Course/Course/Presentation/CourseRouter.swift @@ -59,6 +59,8 @@ public protocol CourseRouter: BaseRouter { downloads: [DownloadDataTask], manager: DownloadManagerProtocol ) + + func showDatesAndCalendar() } // Mark - For testing and SwiftUI preview @@ -116,5 +118,7 @@ public class CourseRouterMock: BaseRouterMock, CourseRouter { downloads: [Core.DownloadDataTask], manager: Core.DownloadManagerProtocol ) {} + + public func showDatesAndCalendar() {} } #endif diff --git a/Course/Course/Presentation/Dates/CourseDatesView.swift b/Course/Course/Presentation/Dates/CourseDatesView.swift index d63722f3f..fba76f5fe 100644 --- a/Course/Course/Presentation/Dates/CourseDatesView.swift +++ b/Course/Course/Presentation/Dates/CourseDatesView.swift @@ -114,7 +114,8 @@ public struct CourseDatesView: View { } else { return DatesSuccessView( title: title, - message: message + message: message, + selectedTab: .dates ) { viewModel.resetEventState() } @@ -165,10 +166,11 @@ struct CourseDateListView: View { collapsed: $collapsed ) VStack(alignment: .leading, spacing: 0) { + + CalendarSyncStatusView(status: viewModel.syncStatus(), router: viewModel.router) + .padding(.bottom, 16) + if !courseDates.hasEnded { - CalendarSyncView(courseID: courseID, viewModel: viewModel) - .padding(.bottom, 16) - DatesStatusInfoView( datesBannerInfo: courseDates.datesBannerInfo, courseID: courseID, @@ -404,42 +406,6 @@ 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 { @@ -503,13 +469,14 @@ struct CourseDatesView_Previews: PreviewProvider { config: ConfigMock(), courseID: "", courseName: "", - analytics: CourseAnalyticsMock() + analytics: CourseAnalyticsMock(), + calendarManager: CalendarManagerMock() ) CourseDatesView( courseID: "", coordinate: .constant(0), - collapsed: .constant(false), + collapsed: .constant(false), viewModel: viewModel) } } diff --git a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift index 8ee011025..c4bae6933 100644 --- a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift +++ b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift @@ -24,15 +24,6 @@ public class CourseDatesViewModel: ObservableObject { @Published var courseDates: CourseDates? @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 { @@ -42,21 +33,6 @@ public class CourseDatesViewModel: ObservableObject { } } - var calendarState: Bool { - get { - return calendar.syncOn - } - set { - if newValue { - trackCalendarSyncToggle(action: .on) - handleCalendar() - } else { - trackCalendarSyncToggle(action: .off) - showRemoveCalendarAlert() - } - } - } - private let interactor: CourseInteractorProtocol let cssInjector: CSSInjector let router: CourseRouter @@ -66,6 +42,7 @@ public class CourseDatesViewModel: ObservableObject { let courseName: String var courseStructure: CourseStructure? let analytics: CourseAnalytics + let calendarManager: CalendarManagerProtocol public init( interactor: CourseInteractorProtocol, @@ -75,7 +52,8 @@ public class CourseDatesViewModel: ObservableObject { config: ConfigProtocol, courseID: String, courseName: String, - analytics: CourseAnalytics + analytics: CourseAnalytics, + calendarManager: CalendarManagerProtocol ) { self.interactor = interactor self.router = router @@ -85,6 +63,7 @@ public class CourseDatesViewModel: ObservableObject { self.courseID = courseID self.courseName = courseName self.analytics = analytics + self.calendarManager = calendarManager addObservers() } @@ -116,7 +95,6 @@ public class CourseDatesViewModel: ObservableObject { return } isShowProgress = false - addCourseEventsIfNecessary() } catch let error { isShowProgress = false if error.isInternetError || error is NoCachedDataError { @@ -144,18 +122,21 @@ public class CourseDatesViewModel: ObservableObject { func getCourseStructure(courseID: String) async { do { courseStructure = try await interactor.getLoadedCourseBlocks(courseID: courseID) - isOn = calendarState } catch _ { errorMessage = CourseLocalization.Error.componentNotFount } } + func syncStatus() -> SyncStatus { + return calendarManager.courseStatus(courseID: courseID) + } + @MainActor 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) + NotificationCenter.default.post(name: .shiftCourseDates, object: (courseID, courseName)) isShowProgress = false trackPLSuccessEvent( .plsShiftDatesSuccess, @@ -226,212 +207,6 @@ extension CourseDatesViewModel { } extension CourseDatesViewModel { - private func handleCalendar() { - calendar.requestAccess { [weak self] _, previousStatus, status in - 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() - } - } - } - } - - @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?.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() - } - }, - 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?.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 - } - - }, - type: .removeCalendar - ) - } - - private func showEventsAddedSuccessAlert() { - if calendar.isModalPresented { - trackCalendarSyncSnackbar(snackbar: .added) - eventState = .addedCalendar - return - } - calendar.isModalPresented = true - router.presentAlert( - alertTitle: "", - alertMessage: CourseLocalization.CourseDates.datesAddedAlertMessage( - calendar.calendarName - ), - 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) - } - }, - 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() - } - } - } - - @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?.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 - self?.calendar.syncOn = false - self?.addCourseEvents(trackAnalytics: false) { [weak self] calendarEventsAdded in - self?.isOn = calendarEventsAdded - if calendarEventsAdded { - self?.trackCalendarSyncSnackbar(snackbar: .updated) - 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) { analytics.datesComponentTapped( @@ -476,33 +251,3 @@ 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/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 381decc63..bb51fa74a 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -61,9 +61,6 @@ public struct CourseOutlineView: View { group.addTask { await viewModel.getCourseBlocks(courseID: courseID, withProgress: false) } - group.addTask { - await viewModel.getCourseDeadlineInfo(courseID: courseID, withProgress: false) - } } }) { DynamicOffsetView( @@ -72,18 +69,6 @@ public struct CourseOutlineView: View { ) RefreshProgressView(isShowRefresh: $viewModel.isShowRefresh) VStack(alignment: .leading) { - if let courseDeadlineInfo = viewModel.courseDeadlineInfo, - courseDeadlineInfo.datesBannerInfo.status == .resetDatesBanner, - !courseDeadlineInfo.hasEnded, - !isVideo { - DatesStatusInfoView( - datesBannerInfo: courseDeadlineInfo.datesBannerInfo, - courseID: courseID, - courseContainerViewModel: viewModel, - screen: .courseDashbaord - ) - .padding(.horizontal, 16) - } downloadQualityBars certificateView @@ -137,17 +122,6 @@ public struct CourseOutlineView: View { } .accessibilityAction {} - if viewModel.dueDatesShifted && !isVideo { - DatesSuccessView( - title: CourseLocalization.CourseDates.toastSuccessTitle, - message: CourseLocalization.CourseDates.toastSuccessMessage, - selectedTab: .course, - courseContainerViewModel: viewModel - ) { - selection = dateTabIndex - } - } - // MARK: - Offline mode SnackBar OfflineSnackBarView( connectivity: viewModel.connectivity, diff --git a/Course/Course/Presentation/Subviews/CalendarSyncStatusView.swift b/Course/Course/Presentation/Subviews/CalendarSyncStatusView.swift new file mode 100644 index 000000000..c949a9d13 --- /dev/null +++ b/Course/Course/Presentation/Subviews/CalendarSyncStatusView.swift @@ -0,0 +1,71 @@ +// +// CalendarSyncStatusView.swift +// Course +// +// Created by  Stepanok Ivan on 12.06.2024. +// + +import SwiftUI +import Core +import Theme + +struct CalendarSyncStatusView: View { + + var status: SyncStatus + let router: CourseRouter + + var body: some View { + HStack { + icon + Text(statusText) + .font(Theme.Fonts.titleSmall) + Spacer() + } + .frame(height: 40) + .padding(.horizontal, 16) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.datesSectionStroke, lineWidth: 2) + ) + .background(Theme.Colors.datesSectionBackground) + .onTapGesture { + router.showDatesAndCalendar() + } + } + + private var icon: Image { + switch status { + case .synced: + return CoreAssets.synced.swiftUIImage + case .failed: + return CoreAssets.syncFailed.swiftUIImage + case .offline: + return CoreAssets.syncOffline.swiftUIImage + } + } + + private var statusText: String { + switch status { + case .synced: + return CourseLocalization.CalendarSyncStatus.synced + case .failed: + return CourseLocalization.CalendarSyncStatus.failed + case .offline: + return CourseLocalization.CalendarSyncStatus.offline + } + } +} + +#if DEBUG +struct CalendarSyncStatusView_Previews: PreviewProvider { + static var previews: some View { + VStack { + CalendarSyncStatusView(status: .synced, router: CourseRouterMock()) + CalendarSyncStatusView(status: .failed, router: CourseRouterMock()) + CalendarSyncStatusView(status: .offline, router: CourseRouterMock()) + } + .loadFonts() + .padding() + } +} +#endif diff --git a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift index 8f152fdd9..22bee864a 100644 --- a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift +++ b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift @@ -379,7 +379,7 @@ struct CustomDisclosureGroup_Previews: PreviewProvider { courseStart: Date(), courseEnd: nil, enrollmentStart: Date(), - enrollmentEnd: nil, + enrollmentEnd: nil, lastVisitedBlockID: nil, coreAnalytics: CoreAnalyticsMock() ) diff --git a/Course/Course/Presentation/Subviews/DatesSuccessView/DatesSuccessView.swift b/Course/Course/Presentation/Subviews/DatesSuccessView/DatesSuccessView.swift index 2b9040683..3adfe607c 100644 --- a/Course/Course/Presentation/Subviews/DatesSuccessView/DatesSuccessView.swift +++ b/Course/Course/Presentation/Subviews/DatesSuccessView/DatesSuccessView.swift @@ -18,7 +18,7 @@ public struct DatesSuccessView: View { private var title: String private var message: String - var selectedTab: Tab? + var selectedTab: Tab var courseDatesViewModel: CourseDatesViewModel? var courseContainerViewModel: CourseContainerViewModel? var action: () -> Void = {} @@ -29,10 +29,12 @@ public struct DatesSuccessView: View { init ( title: String, message: String, + selectedTab: Tab, dismissAction: @escaping () -> Void ) { self.title = title self.message = message + self.selectedTab = selectedTab self.dismissAction = dismissAction } @@ -147,7 +149,8 @@ struct DatesSuccessView_Previews: PreviewProvider { static var previews: some View { DatesSuccessView( title: CourseLocalization.CourseDates.toastSuccessTitle, - message: CourseLocalization.CourseDates.toastSuccessMessage + message: CourseLocalization.CourseDates.toastSuccessMessage, + selectedTab: .course ) {} } } diff --git a/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityBarView.swift b/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityBarView.swift index 56d4fa158..8617dcb58 100644 --- a/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityBarView.swift +++ b/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityBarView.swift @@ -9,7 +9,6 @@ import SwiftUI import Core import Theme import Combine -import Profile struct VideoDownloadQualityBarView: View { diff --git a/Course/Course/SwiftGen/Strings.swift b/Course/Course/SwiftGen/Strings.swift index 91e479f03..8cf2f60a2 100644 --- a/Course/Course/SwiftGen/Strings.swift +++ b/Course/Course/SwiftGen/Strings.swift @@ -40,6 +40,14 @@ public enum CourseLocalization { /// Warning public static let warning = CourseLocalization.tr("Localizable", "ALERT.WARNING", fallback: "Warning") } + public enum CalendarSyncStatus { + /// Calendar Sync Failed + public static let failed = CourseLocalization.tr("Localizable", "CALENDAR_SYNC_STATUS.FAILED", fallback: "Calendar Sync Failed") + /// Offline + public static let offline = CourseLocalization.tr("Localizable", "CALENDAR_SYNC_STATUS.OFFLINE", fallback: "Offline") + /// Synced to Calendar + public static let synced = CourseLocalization.tr("Localizable", "CALENDAR_SYNC_STATUS.SYNCED", fallback: "Synced to Calendar") + } public enum Course { /// Due Today public static let dueToday = CourseLocalization.tr("Localizable", "COURSE.DUE_TODAY", fallback: "Due Today") @@ -165,36 +173,6 @@ public enum CourseLocalization { 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 { diff --git a/Course/Course/en.lproj/Localizable.strings b/Course/Course/en.lproj/Localizable.strings index f4a37b8a4..424ecf737 100644 --- a/Course/Course/en.lproj/Localizable.strings +++ b/Course/Course/en.lproj/Localizable.strings @@ -103,21 +103,6 @@ "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"; -"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"; @@ -126,3 +111,6 @@ "COURSE.DUE_TOMORROW" = "Due Tomorrow"; "COURSE.PROGRESS_COMPLETED" = "%@ of %@ assignments complete"; +"CALENDAR_SYNC_STATUS.SYNCED" = "Synced to Calendar"; +"CALENDAR_SYNC_STATUS.FAILED" = "Calendar Sync Failed"; +"CALENDAR_SYNC_STATUS.OFFLINE" = "Offline"; diff --git a/Course/Course/uk.lproj/Localizable.strings b/Course/Course/uk.lproj/Localizable.strings index b5a3746f6..76d23b095 100644 --- a/Course/Course/uk.lproj/Localizable.strings +++ b/Course/Course/uk.lproj/Localizable.strings @@ -102,21 +102,6 @@ "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"; -"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"; @@ -125,3 +110,6 @@ "COURSE.DUE_TOMORROW" = "Закінчується завтра"; "COURSE.PROGRESS_COMPLETED" = "%@ з %@ завдань виконано"; +"CALENDAR_SYNC_STATUS.SYNCED" = "Синхронізовано з календарем"; +"CALENDAR_SYNC_STATUS.FAILED" = "Помилка синхронізації календаря"; +"CALENDAR_SYNC_STATUS.OFFLINE" = "Офлайн"; diff --git a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift index 19ae08286..c88637989 100644 --- a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift @@ -61,7 +61,8 @@ final class CourseDateViewModelTests: XCTestCase { config: config, courseID: "1", courseName: "a", - analytics: CourseAnalyticsMock() + analytics: CourseAnalyticsMock(), + calendarManager: CalendarManagerMock() ) await viewModel.getCourseDates(courseID: "1") @@ -91,7 +92,8 @@ final class CourseDateViewModelTests: XCTestCase { config: config, courseID: "1", courseName: "a", - analytics: CourseAnalyticsMock() + analytics: CourseAnalyticsMock(), + calendarManager: CalendarManagerMock() ) await viewModel.getCourseDates(courseID: "1") @@ -121,7 +123,8 @@ final class CourseDateViewModelTests: XCTestCase { config: config, courseID: "1", courseName: "a", - analytics: CourseAnalyticsMock() + analytics: CourseAnalyticsMock(), + calendarManager: CalendarManagerMock() ) await viewModel.getCourseDates(courseID: "1") diff --git a/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents b/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents index d3bea41fc..525156723 100644 --- a/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents +++ b/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -39,8 +39,8 @@ - - + + @@ -54,8 +54,8 @@ - - + + diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 24d602f45..02d6d8680 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -12,6 +12,7 @@ 0218196528F734FA00202564 /* Discussion.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0218196328F734FA00202564 /* Discussion.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 0219C67728F4347600D64452 /* Course.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 0219C67628F4347600D64452 /* Course.framework */; }; 0219C67828F4347600D64452 /* Course.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0219C67628F4347600D64452 /* Course.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; + 022213D22C0E08E500B917E6 /* ProfilePersistence.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022213D12C0E08E500B917E6 /* ProfilePersistence.swift */; }; 024E69202AEFC3FB00FA0B59 /* MainScreenViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024E691F2AEFC3FB00FA0B59 /* MainScreenViewModel.swift */; }; 025AD4AC2A6FB95C00AB8FA7 /* DatabaseManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025AD4AB2A6FB95C00AB8FA7 /* DatabaseManager.swift */; }; 025DE1A428DB4DAE0053E0F4 /* Profile.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 025DE1A328DB4DAE0053E0F4 /* Profile.framework */; }; @@ -98,6 +99,7 @@ 020CA5D82AA0A25300970AAF /* AppStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStorage.swift; sourceTree = ""; }; 0218196328F734FA00202564 /* Discussion.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Discussion.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0219C67628F4347600D64452 /* Course.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Course.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 022213D12C0E08E500B917E6 /* ProfilePersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePersistence.swift; sourceTree = ""; }; 02450ABD29C35FF20094E2D0 /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 024E691F2AEFC3FB00FA0B59 /* MainScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainScreenViewModel.swift; sourceTree = ""; }; 025AD4AB2A6FB95C00AB8FA7 /* DatabaseManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatabaseManager.swift; sourceTree = ""; }; @@ -196,6 +198,7 @@ 0293A2042A6FCD430090A336 /* CoursePersistence.swift */, 0293A2062A6FCDA30090A336 /* DiscoveryPersistence.swift */, 0293A2082A6FCDE50090A336 /* DashboardPersistence.swift */, + 022213D12C0E08E500B917E6 /* ProfilePersistence.swift */, 020CA5D82AA0A25300970AAF /* AppStorage.swift */, ); path = Data; @@ -641,6 +644,7 @@ A50066952B614DEF0024680B /* BrazeListener.swift in Sources */, 071009C928D1DB3F00344290 /* ScreenAssembly.swift in Sources */, A59568972B61653700ED4F90 /* DeepLink.swift in Sources */, + 022213D22C0E08E500B917E6 /* ProfilePersistence.swift in Sources */, A5C10D8F2B861A70008E864D /* SegmentAnalyticsService.swift in Sources */, A59568992B616D9400ED4F90 /* PushLink.swift in Sources */, ); diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index a39285e7e..5c4639e15 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -180,6 +180,15 @@ class AppAssembly: Assembly { config: r.resolve(ConfigProtocol.self)! ) }.inObjectScope(.container) + + container.register(CalendarManagerProtocol.self) { r in + CalendarManager( + persistence: r.resolve(ProfilePersistenceProtocol.self)!, + interactor: r.resolve(ProfileInteractorProtocol.self)!, + profileStorage: r.resolve(ProfileStorage.self)! + ) + } + .inObjectScope(.container) container.register(DeepLinkManager.self) { r in DeepLinkManager( diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 04ddc0514..f76ef7808 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -40,6 +40,8 @@ class ScreenAssembly: Assembly { analytics: r.resolve(MainScreenAnalytics.self)!, config: r.resolve(ConfigProtocol.self)!, profileInteractor: r.resolve(ProfileInteractorProtocol.self)!, + appStorage: r.resolve(AppStorage.self)!, + calendarManager: r.resolve(CalendarManagerProtocol.self)!, sourceScreen: sourceScreen ) } @@ -189,6 +191,11 @@ class ScreenAssembly: Assembly { // MARK: Profile + // MARK: Course + container.register(ProfilePersistenceProtocol.self) { r in + ProfilePersistence(context: r.resolve(DatabaseManager.self)!.context) + } + container.register(ProfileRepositoryProtocol.self) { r in ProfileRepository( api: r.resolve(API.self)!, @@ -235,10 +242,16 @@ class ScreenAssembly: Assembly { container.register(DatesAndCalendarViewModel.self) { r in DatesAndCalendarViewModel( - router: r.resolve(ProfileRouter.self)! + router: r.resolve(ProfileRouter.self)!, + interactor: r.resolve(ProfileInteractorProtocol.self)!, + profileStorage: r.resolve(ProfileStorage.self)!, + persistence: r.resolve(ProfilePersistenceProtocol.self)!, + calendarManager: r.resolve(CalendarManagerProtocol.self)!, + connectivity: r.resolve(ConnectivityProtocol.self)! ) } - + .inObjectScope(.weak) + container.register(ManageAccountViewModel.self) { r in ManageAccountViewModel( router: r.resolve(ProfileRouter.self)!, @@ -472,7 +485,8 @@ class ScreenAssembly: Assembly { config: r.resolve(ConfigProtocol.self)!, courseID: courseID, courseName: courseName, - analytics: r.resolve(CourseAnalytics.self)! + analytics: r.resolve(CourseAnalytics.self)!, + calendarManager: r.resolve(CalendarManagerProtocol.self)! ) } diff --git a/OpenEdX/Data/AppStorage.swift b/OpenEdX/Data/AppStorage.swift index 0d75f2851..5f8b8f924 100644 --- a/OpenEdX/Data/AppStorage.swift +++ b/OpenEdX/Data/AppStorage.swift @@ -11,6 +11,7 @@ import Core import Profile import WhatsNew import Course +import Theme public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseStorage { @@ -216,7 +217,26 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto } } } - + + public var calendarSettings: CalendarSettings? { + get { + guard let userJson = userDefaults.data(forKey: KEY_CALENDAR_SETTINGS) else { + return nil + } + return try? JSONDecoder().decode(CalendarSettings.self, from: userJson) + } + set(newValue) { + if let settings = newValue { + let encoder = JSONEncoder() + if let encoded = try? encoder.encode(settings) { + userDefaults.set(encoded, forKey: KEY_CALENDAR_SETTINGS) + } + } else { + userDefaults.set(nil, forKey: KEY_CALENDAR_SETTINGS) + } + } + } + public var resetAppSupportDirectoryUserData: Bool? { get { return userDefaults.bool(forKey: KEY_RESET_APP_SUPPORT_DIRECTORY_USER_DATA) @@ -230,6 +250,74 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto } } + public var lastCalendarName: String? { + get { + return userDefaults.string(forKey: KEY_LAST_CALENDAR_NAME) + } + set(newValue) { + if let newValue { + userDefaults.set(newValue, forKey: KEY_LAST_CALENDAR_NAME) + } else { + userDefaults.removeObject(forKey: KEY_LAST_CALENDAR_NAME) + } + } + } + + public var lastLoginUsername: String? { + get { + return userDefaults.string(forKey: KEY_LAST_LOGIN_USERNAME) + } + set(newValue) { + if let newValue { + userDefaults.set(newValue, forKey: KEY_LAST_LOGIN_USERNAME) + } else { + userDefaults.removeObject(forKey: KEY_LAST_LOGIN_USERNAME) + } + } + } + + public var lastCalendarUpdateDate: Date? { + get { + guard let dateString = userDefaults.string(forKey: KEY_LAST_CALENDAR_UPDATE_DATE) else { + return nil + } + return Date(iso8601: dateString) + } + set(newValue) { + if let newValue { + userDefaults.set(newValue.dateToString(style: .iso8601), forKey: KEY_LAST_CALENDAR_UPDATE_DATE) + } else { + userDefaults.removeObject(forKey: KEY_LAST_CALENDAR_UPDATE_DATE) + } + } + } + + public var hideInactiveCourses: Bool? { + get { + return userDefaults.bool(forKey: KEY_HIDE_INACTIVE_COURSES) + } + set(newValue) { + if let newValue { + userDefaults.set(newValue, forKey: KEY_HIDE_INACTIVE_COURSES) + } else { + userDefaults.removeObject(forKey: KEY_HIDE_INACTIVE_COURSES) + } + } + } + + public var firstCalendarUpdate: Bool? { + get { + return userDefaults.bool(forKey: KEY_FIRST_CALENDAR_UPDATE) + } + set(newValue) { + if let newValue { + userDefaults.set(newValue, forKey: KEY_FIRST_CALENDAR_UPDATE) + } else { + userDefaults.removeObject(forKey: KEY_FIRST_CALENDAR_UPDATE) + } + } + } + public func clear() { accessToken = nil refreshToken = nil @@ -251,5 +339,11 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto private let KEY_APPLE_SIGN_FULLNAME = "appleSignFullName" private let KEY_APPLE_SIGN_EMAIL = "appleSignEmail" private let KEY_ALLOWED_DOWNLOAD_LARGE_FILE = "allowedDownloadLargeFile" + private let KEY_CALENDAR_SETTINGS = "calendarSettings" + private let KEY_LAST_LOGIN_USERNAME = "lastLoginUsername" + private let KEY_LAST_CALENDAR_NAME = "lastCalendarName" + private let KEY_LAST_CALENDAR_UPDATE_DATE = "lastCalendarUpdateDate" + private let KEY_HIDE_INACTIVE_COURSES = "hideInactiveCourses" + private let KEY_FIRST_CALENDAR_UPDATE = "firstCalendarUpdate" private let KEY_RESET_APP_SUPPORT_DIRECTORY_USER_DATA = "resetAppSupportDirectoryUserData" } diff --git a/OpenEdX/Data/DashboardPersistence.swift b/OpenEdX/Data/DashboardPersistence.swift index 2ac59a583..ab0f14e52 100644 --- a/OpenEdX/Data/DashboardPersistence.swift +++ b/OpenEdX/Data/DashboardPersistence.swift @@ -234,17 +234,13 @@ public class DashboardPersistence: DashboardPersistenceProtocol { context.perform {[context] in let fetchRequest1: NSFetchRequest = CDDashboardCourse.fetchRequest() let batchDeleteRequest1 = NSBatchDeleteRequest(fetchRequest: fetchRequest1) - - let fetchRequest2: NSFetchRequest = CDPrimaryCourse.fetchRequest() + + let fetchRequest2: NSFetchRequest = CDMyEnrollments.fetchRequest() let batchDeleteRequest2 = NSBatchDeleteRequest(fetchRequest: fetchRequest2) - - let fetchRequest3: NSFetchRequest = CDMyEnrollments.fetchRequest() - let batchDeleteRequest3 = NSBatchDeleteRequest(fetchRequest: fetchRequest3) - + do { try context.execute(batchDeleteRequest1) try context.execute(batchDeleteRequest2) - try context.execute(batchDeleteRequest3) } catch { print("Error when deleting old data:", error) } diff --git a/OpenEdX/Data/DatabaseManager.swift b/OpenEdX/Data/DatabaseManager.swift index b8f71b346..57cd98d45 100644 --- a/OpenEdX/Data/DatabaseManager.swift +++ b/OpenEdX/Data/DatabaseManager.swift @@ -11,6 +11,7 @@ import Core import Discovery import Dashboard import Course +import Profile class DatabaseManager: CoreDataHandlerProtocol { @@ -20,7 +21,8 @@ class DatabaseManager: CoreDataHandlerProtocol { Bundle(for: CoreBundle.self), Bundle(for: DiscoveryBundle.self), Bundle(for: DashboardBundle.self), - Bundle(for: CourseBundle.self) + Bundle(for: CourseBundle.self), + Bundle(for: ProfileBundle.self) ] private lazy var persistentContainer: NSPersistentContainer = { diff --git a/OpenEdX/Data/ProfilePersistence.swift b/OpenEdX/Data/ProfilePersistence.swift new file mode 100644 index 000000000..9b37befc9 --- /dev/null +++ b/OpenEdX/Data/ProfilePersistence.swift @@ -0,0 +1,168 @@ +// +// ProfilePersistence.swift +// OpenEdX +// +// Created by  Stepanok Ivan on 03.06.2024. +// + +import Profile +import Core +import Foundation +import CoreData + +public class ProfilePersistence: ProfilePersistenceProtocol { + + private var context: NSManagedObjectContext + + public init(context: NSManagedObjectContext) { + self.context = context + } + + public func getCourseState(courseID: String) -> CourseCalendarState? { + context.performAndWait { + let fetchRequest: NSFetchRequest = CDCourseCalendarState.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "courseID == %@", courseID) + do { + if let result = try context.fetch(fetchRequest).first, + let courseID = result.courseID, + let checksum = result.checksum { + return CourseCalendarState(courseID: courseID, checksum: checksum) + } + } catch { + debugLog("⛔️ Error fetching CourseCalendarEvents: \(error)") + } + return nil + } + } + + public func getAllCourseStates() -> [CourseCalendarState] { + var states: [CourseCalendarState] = [] + context.performAndWait { + let fetchRequest: NSFetchRequest = CDCourseCalendarState.fetchRequest() + do { + let results = try context.fetch(fetchRequest) + states = results.compactMap { result in + if let courseID = result.courseID, let checksum = result.checksum { + return CourseCalendarState(courseID: courseID, checksum: checksum) + } + return nil + } + } catch { + debugLog("⛔️ Error fetching CourseCalendarEvents: \(error)") + } + } + return states + } + + public func saveCourseState(state: CourseCalendarState) { + context.performAndWait { + let newState = CDCourseCalendarState(context: context) + context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + newState.courseID = state.courseID + newState.checksum = state.checksum + do { + try context.save() + } catch { + debugLog("⛔️ Error saving CourseCalendarEvent: \(error)") + } + } + } + + public func removeCourseState(courseID: String) { + context.performAndWait { + let fetchRequest: NSFetchRequest = CDCourseCalendarState.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "courseID == %@", courseID) + do { + if let result = try context.fetch(fetchRequest).first { + if let object = result as? NSManagedObject { + context.delete(object) + try context.save() + } + } + } catch { + debugLog("⛔️ Error removing CDCourseCalendarState: \(error)") + } + } + } + + public func deleteAllCourseStatesAndEvents() { + let fetchRequestCalendarStates: NSFetchRequest = CDCourseCalendarState.fetchRequest() + let deleteRequestCalendarStates = NSBatchDeleteRequest(fetchRequest: fetchRequestCalendarStates) + let fetchRequestCalendarEvents: NSFetchRequest = CDCourseCalendarEvent.fetchRequest() + let deleteRequestCalendarEvents = NSBatchDeleteRequest(fetchRequest: fetchRequestCalendarEvents) + + do { + try context.execute(deleteRequestCalendarStates) + try context.execute(deleteRequestCalendarEvents) + try context.save() + } catch { + debugLog("⛔️⛔️⛔️⛔️⛔️", error) + } + } + + public func saveCourseCalendarEvent(_ event: CourseCalendarEvent) { + context.performAndWait { + let newEvent = CDCourseCalendarEvent(context: context) + context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + newEvent.courseID = event.courseID + newEvent.eventIdentifier = event.eventIdentifier + do { + try context.save() + } catch { + debugLog("⛔️ Error saving CourseCalendarEvent: \(error)") + } + } + } + + public func removeCourseCalendarEvents(for courseId: String) { + context.performAndWait { + let fetchRequest: NSFetchRequest = CDCourseCalendarEvent.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "courseID == %@", courseId) + do { + let results = try context.fetch(fetchRequest) + results.forEach { result in + if let object = result as? NSManagedObject { + context.delete(object) + } + } + try context.save() + } catch { + debugLog("⛔️ Error removing CourseCalendarEvents: \(error)") + } + } + } + + public func removeAllCourseCalendarEvents() { + context.performAndWait { + let fetchRequest: NSFetchRequest = CDCourseCalendarEvent.fetchRequest() + let deleteRequest = NSBatchDeleteRequest(fetchRequest: fetchRequest) + do { + try context.execute(deleteRequest) + try context.save() + } catch { + debugLog("⛔️ Error removing CourseCalendarEvents: \(error)") + } + } + } + + public func getCourseCalendarEvents(for courseId: String) -> [CourseCalendarEvent] { + var events: [CourseCalendarEvent] = [] + context.performAndWait { + let fetchRequest: NSFetchRequest = CDCourseCalendarEvent.fetchRequest() + fetchRequest.predicate = NSPredicate(format: "courseID == %@", courseId) + do { + let results = try context.fetch(fetchRequest) + events = results.compactMap { result in + if let courseID = result.courseID, let eventIdentifier = result.eventIdentifier { + return CourseCalendarEvent(courseID: courseID, eventIdentifier: eventIdentifier) + } + return nil + } + } catch { + debugLog("⛔️ Error fetching CourseCalendarEvents: \(error)") + } + } + return events + } + +} diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index fce1cca5d..186ff6329 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -734,7 +734,15 @@ public class Router: AuthorizationRouter, public func showDatesAndCalendar() { let viewModel = Container.shared.resolve(DatesAndCalendarViewModel.self)! - let view = DatesAndCalendarView(viewModel: viewModel) + let storage = Container.shared.resolve(ProfileStorage.self) + + let view: AnyView + if storage?.calendarSettings == nil { + view = AnyView(DatesAndCalendarView(viewModel: viewModel)) + } else { + view = AnyView(SyncCalendarOptionsView(viewModel: viewModel)) + } + let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) } diff --git a/OpenEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift index 7e3e30d60..72fa66b56 100644 --- a/OpenEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -182,6 +182,7 @@ struct MainScreenView: View { .onFirstAppear { Task { await viewModel.prefetchDataForOffline() + await viewModel.loadCalendar() } } .accentColor(Theme.Colors.accentXColor) diff --git a/OpenEdX/View/MainScreenViewModel.swift b/OpenEdX/View/MainScreenViewModel.swift index d3409b191..740d0fd93 100644 --- a/OpenEdX/View/MainScreenViewModel.swift +++ b/OpenEdX/View/MainScreenViewModel.swift @@ -8,6 +8,8 @@ import Foundation import Core import Profile +import Swinject +import Combine public enum MainTab { case discovery @@ -17,29 +19,45 @@ public enum MainTab { } final class MainScreenViewModel: ObservableObject { - + private let analytics: MainScreenAnalytics let config: ConfigProtocol - let profileInteractor: ProfileInteractorProtocol + private let profileInteractor: ProfileInteractorProtocol var sourceScreen: LogistrationSourceScreen - + private var appStorage: CoreStorage & ProfileStorage + private let calendarManager: CalendarManagerProtocol + private var cancellables = Set() + @Published var selection: MainTab = .dashboard - + init(analytics: MainScreenAnalytics, config: ConfigProtocol, profileInteractor: ProfileInteractorProtocol, + appStorage: CoreStorage & ProfileStorage, + calendarManager: CalendarManagerProtocol, sourceScreen: LogistrationSourceScreen = .default ) { self.analytics = analytics self.config = config self.profileInteractor = profileInteractor + self.appStorage = appStorage + self.calendarManager = calendarManager self.sourceScreen = sourceScreen + + NotificationCenter.default.publisher(for: .shiftCourseDates, object: nil) + .sink { notification in + guard let (courseID, courseName) = notification.object as? (String, String) else { return } + Task { + await self.updateCourseDates(courseID: courseID, courseName: courseName) + } + } + .store(in: &cancellables) } - + public func select(tab: MainTab) { selection = tab } - + func trackMainDiscoveryTabClicked() { analytics.mainDiscoveryTabClicked() } @@ -52,7 +70,7 @@ final class MainScreenViewModel: ObservableObject { func trackMainProfileTabClicked() { analytics.mainProfileTabClicked() } - + @MainActor func prefetchDataForOffline() async { if profileInteractor.getMyProfileOffline() == nil { @@ -60,4 +78,69 @@ final class MainScreenViewModel: ObservableObject { } } + func loadCalendar() async { + if let username = appStorage.user?.username { + await updateCalendarIfNeeded(for: username) + } + } +} + +extension MainScreenViewModel { + + // MARK: Update calendar on startup + private func updateCalendarIfNeeded(for username: String) async { + + if username == appStorage.lastLoginUsername { + let today = Date() + let calendar = Calendar.current + + if let lastUpdate = appStorage.lastCalendarUpdateDate { + if calendar.isDateInToday(lastUpdate) { + return + } + } + appStorage.lastCalendarUpdateDate = today + + guard appStorage.calendarSettings?.calendarName != "", + appStorage.calendarSettings?.courseCalendarSync ?? true + else { + debugLog("No calendar for user: \(username)") + return + } + + do { + var coursesForSync = try await profileInteractor.enrollmentsStatus().filter { $0.active } + + let selectedCourses = await calendarManager.filterCoursesBySelected(fetchedCourses: coursesForSync) + + for course in selectedCourses { + if let courseDates = try? await profileInteractor.getCourseDates(courseID: course.courseID), + calendarManager.isDatesChanged(courseID: course.courseID, checksum: courseDates.checksum) { + debugLog("Calendar needs update for courseID: \(course.courseID)") + await calendarManager.removeOutdatedEvents(courseID: course.courseID) + await calendarManager.syncCourse( + courseID: course.courseID, + courseName: course.name, + dates: courseDates + ) + } + } + debugLog("No calendar update needed for username: \(username)") + } catch { + debugLog("Error updating calendar: \(error.localizedDescription)") + } + } else { + appStorage.lastLoginUsername = username + calendarManager.clearAllData(removeCalendar: false) + } + } + + private func updateCourseDates(courseID: String, courseName: String) async { + if let courseDates = try? await profileInteractor.getCourseDates(courseID: courseID), + calendarManager.isDatesChanged(courseID: courseID, checksum: courseDates.checksum) { + debugLog("Calendar update needed for courseID: \(courseID)") + await calendarManager.removeOutdatedEvents(courseID: courseID) + await calendarManager.syncCourse(courseID: courseID, courseName: courseName, dates: courseDates) + } + } } diff --git a/Profile/Data/ProfileStorage.swift b/Profile/Data/ProfileStorage.swift deleted file mode 100644 index 2770f6060..000000000 --- a/Profile/Data/ProfileStorage.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// ProfileStorage.swift -// Profile -// -// Created by  Stepanok Ivan on 30.08.2023. -// - -import Foundation -import Core - -public protocol ProfileStorage { - var userProfile: DataLayer.UserProfile? {get set} -} - -#if DEBUG -public class ProfileStorageMock: ProfileStorage { - - public var userProfile: DataLayer.UserProfile? - - public init() {} -} -#endif diff --git a/Profile/Profile.xcodeproj/project.pbxproj b/Profile/Profile.xcodeproj/project.pbxproj index a0f53c75d..afc5ff356 100644 --- a/Profile/Profile.xcodeproj/project.pbxproj +++ b/Profile/Profile.xcodeproj/project.pbxproj @@ -20,16 +20,21 @@ 021D925528DC92F800ACC565 /* ProfileInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D925428DC92F800ACC565 /* ProfileInteractor.swift */; }; 021D925C28DDADBD00ACC565 /* swiftgen.yml in Resources */ = {isa = PBXBuildFile; fileRef = 021D925B28DDADBD00ACC565 /* swiftgen.yml */; }; 021D925F28DDADE600ACC565 /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 021D926128DDADE600ACC565 /* Localizable.strings */; }; + 022213CD2C0E050B00B917E6 /* CalendarSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022213CC2C0E050B00B917E6 /* CalendarSettings.swift */; }; + 022213D02C0E072400B917E6 /* ProfilePersistenceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022213CF2C0E072400B917E6 /* ProfilePersistenceProtocol.swift */; }; + 022213D72C0E18A300B917E6 /* CourseCalendarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022213D62C0E18A200B917E6 /* CourseCalendarState.swift */; }; 022301E42BF4B7610028A287 /* SyncCalendarOptionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022301E32BF4B7610028A287 /* SyncCalendarOptionsView.swift */; }; 022301E62BF4B7A20028A287 /* AssignmentStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022301E52BF4B7A20028A287 /* AssignmentStatusView.swift */; }; 022301E82BF4C4E70028A287 /* ToggleWithDescriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022301E72BF4C4E70028A287 /* ToggleWithDescriptionView.swift */; }; 0248F9B128DDB09D0041327E /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0248F9B028DDB09D0041327E /* Strings.swift */; }; + 0250C1AD2C231E2500B9E554 /* ProfileCoreModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 022213D32C0E092300B917E6 /* ProfileCoreModel.xcdatamodeld */; }; 0259104429C39C9E004B5A55 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0259104329C39C9E004B5A55 /* SettingsView.swift */; }; 0259104629C39CCF004B5A55 /* SettingsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0259104529C39CCF004B5A55 /* SettingsViewModel.swift */; }; 0259104829C3A5F0004B5A55 /* VideoQualityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0259104729C3A5F0004B5A55 /* VideoQualityView.swift */; }; 025DE1A028DB4D9D0053E0F4 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 025DE19F28DB4D9D0053E0F4 /* Core.framework */; }; 0262149229AE57A1008BD75A /* DeleteAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0262149129AE57A1008BD75A /* DeleteAccountView.swift */; }; 0262149429AE57B1008BD75A /* DeleteAccountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0262149329AE57B1008BD75A /* DeleteAccountViewModel.swift */; }; + 027FEF372C1710040037807E /* CourseCalendarEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027FEF362C1710040037807E /* CourseCalendarEvent.swift */; }; 0281D1532BEA9A40006DAD7A /* NewCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0281D1522BEA9A40006DAD7A /* NewCalendarView.swift */; }; 0281D1552BEBA8D9006DAD7A /* DropDownPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0281D1542BEBA8D9006DAD7A /* DropDownPicker.swift */; }; 0288C4EA2BC6AE2D009158B9 /* ManageAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0288C4E92BC6AE2D009158B9 /* ManageAccountView.swift */; }; @@ -42,6 +47,7 @@ 02B089432A9F832200754BD4 /* ProfileStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B089422A9F832200754BD4 /* ProfileStorage.swift */; }; 02D0FD092AD698380020D752 /* UserProfileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D0FD082AD698380020D752 /* UserProfileView.swift */; }; 02D0FD0B2AD6984D0020D752 /* UserProfileViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D0FD0A2AD6984D0020D752 /* UserProfileViewModel.swift */; }; + 02EBC7532C19CD1800BE182C /* CalendarManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EBC7522C19CD1700BE182C /* CalendarManager.swift */; }; 02F175352A4DAD030019CD70 /* ProfileAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F175342A4DAD030019CD70 /* ProfileAnalytics.swift */; }; 02F3BFE7292539850051930C /* ProfileRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F3BFE6292539850051930C /* ProfileRouter.swift */; }; 02F81DDF2BF4D83E002D3604 /* CalendarDialogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F81DDE2BF4D83E002D3604 /* CalendarDialogView.swift */; }; @@ -79,6 +85,10 @@ 021D925428DC92F800ACC565 /* ProfileInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileInteractor.swift; sourceTree = ""; }; 021D925B28DDADBD00ACC565 /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = ""; }; 021D926028DDADE600ACC565 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; + 022213CC2C0E050B00B917E6 /* CalendarSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarSettings.swift; sourceTree = ""; }; + 022213CF2C0E072400B917E6 /* ProfilePersistenceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfilePersistenceProtocol.swift; sourceTree = ""; }; + 022213D42C0E092300B917E6 /* ProfileCoreModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = ProfileCoreModel.xcdatamodel; sourceTree = ""; }; + 022213D62C0E18A200B917E6 /* CourseCalendarState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseCalendarState.swift; sourceTree = ""; }; 022301E32BF4B7610028A287 /* SyncCalendarOptionsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncCalendarOptionsView.swift; sourceTree = ""; }; 022301E52BF4B7A20028A287 /* AssignmentStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AssignmentStatusView.swift; sourceTree = ""; }; 022301E72BF4C4E70028A287 /* ToggleWithDescriptionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToggleWithDescriptionView.swift; sourceTree = ""; }; @@ -89,6 +99,7 @@ 025DE19F28DB4D9D0053E0F4 /* Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0262149129AE57A1008BD75A /* DeleteAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAccountView.swift; sourceTree = ""; }; 0262149329AE57B1008BD75A /* DeleteAccountViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeleteAccountViewModel.swift; sourceTree = ""; }; + 027FEF362C1710040037807E /* CourseCalendarEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseCalendarEvent.swift; sourceTree = ""; }; 0281D1522BEA9A40006DAD7A /* NewCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewCalendarView.swift; sourceTree = ""; }; 0281D1542BEBA8D9006DAD7A /* DropDownPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DropDownPicker.swift; sourceTree = ""; }; 0288C4E92BC6AE2D009158B9 /* ManageAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageAccountView.swift; sourceTree = ""; }; @@ -101,6 +112,7 @@ 02B089422A9F832200754BD4 /* ProfileStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileStorage.swift; sourceTree = ""; }; 02D0FD082AD698380020D752 /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = ""; }; 02D0FD0A2AD6984D0020D752 /* UserProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileViewModel.swift; sourceTree = ""; }; + 02EBC7522C19CD1700BE182C /* CalendarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarManager.swift; sourceTree = ""; }; 02ED50CE29A64BAD008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02F175342A4DAD030019CD70 /* ProfileAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAnalytics.swift; sourceTree = ""; }; 02F3BFE6292539850051930C /* ProfileRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRouter.swift; sourceTree = ""; }; @@ -176,7 +188,6 @@ 020F834028DB4CCD0062FA70 = { isa = PBXGroup; children = ( - 02B089412A9F830D00754BD4 /* Data */, 021D925B28DDADBD00ACC565 /* swiftgen.yml */, 020F834C28DB4CCD0062FA70 /* Profile */, 02A9A91B2978194A00B55797 /* ProfileTests */, @@ -211,11 +222,13 @@ 021C90D32BC986A4004876AF /* DatesAndCalendar */ = { isa = PBXGroup; children = ( + 022213CB2C0E04FB00B917E6 /* Models */, 0281D1512BEA9A2D006DAD7A /* Elements */, 021C90D42BC986B3004876AF /* DatesAndCalendarView.swift */, 022301E32BF4B7610028A287 /* SyncCalendarOptionsView.swift */, 02F81DE02BF4F009002D3604 /* CoursesToSyncView.swift */, 021C90D62BC98734004876AF /* DatesAndCalendarViewModel.swift */, + 02EBC7522C19CD1700BE182C /* CalendarManager.swift */, ); path = DatesAndCalendar; sourceTree = ""; @@ -237,6 +250,8 @@ 021D924928DC882B00ACC565 /* Data */ = { isa = PBXGroup; children = ( + 02B089422A9F832200754BD4 /* ProfileStorage.swift */, + 022213CE2C0E070F00B917E6 /* Persistence */, 021D924A28DC883000ACC565 /* Network */, 021D924D28DC88BB00ACC565 /* ProfileRepository.swift */, ); @@ -268,6 +283,25 @@ path = SwiftGen; sourceTree = ""; }; + 022213CB2C0E04FB00B917E6 /* Models */ = { + isa = PBXGroup; + children = ( + 022213CC2C0E050B00B917E6 /* CalendarSettings.swift */, + 022213D62C0E18A200B917E6 /* CourseCalendarState.swift */, + 027FEF362C1710040037807E /* CourseCalendarEvent.swift */, + ); + path = Models; + sourceTree = ""; + }; + 022213CE2C0E070F00B917E6 /* Persistence */ = { + isa = PBXGroup; + children = ( + 022213CF2C0E072400B917E6 /* ProfilePersistenceProtocol.swift */, + 022213D32C0E092300B917E6 /* ProfileCoreModel.xcdatamodeld */, + ); + path = Persistence; + sourceTree = ""; + }; 02362C8329350C0C00134A5B /* Model */ = { isa = PBXGroup; children = ( @@ -345,14 +379,6 @@ path = ProfileTests; sourceTree = ""; }; - 02B089412A9F830D00754BD4 /* Data */ = { - isa = PBXGroup; - children = ( - 02B089422A9F832200754BD4 /* ProfileStorage.swift */, - ); - path = Data; - sourceTree = ""; - }; 02D0FD072AD695E10020D752 /* UserProfile */ = { isa = PBXGroup; children = ( @@ -635,7 +661,9 @@ 0796C8C929B7905300444B05 /* ProfileBottomSheet.swift in Sources */, 021D924C28DC884A00ACC565 /* ProfileEndpoint.swift in Sources */, 02F81DE12BF4F009002D3604 /* CoursesToSyncView.swift in Sources */, + 027FEF372C1710040037807E /* CourseCalendarEvent.swift in Sources */, 0281D1532BEA9A40006DAD7A /* NewCalendarView.swift in Sources */, + 02EBC7532C19CD1800BE182C /* CalendarManager.swift in Sources */, BAD9CA3F2B29BF5C00DE790A /* ProfileSupportInfoView.swift in Sources */, 022301E82BF4C4E70028A287 /* ToggleWithDescriptionView.swift in Sources */, 022301E42BF4B7610028A287 /* SyncCalendarOptionsView.swift in Sources */, @@ -650,7 +678,11 @@ 029301DA2938948500E99AB8 /* ProfileType.swift in Sources */, 0259104429C39C9E004B5A55 /* SettingsView.swift in Sources */, 02B089432A9F832200754BD4 /* ProfileStorage.swift in Sources */, + 022213D72C0E18A300B917E6 /* CourseCalendarState.swift in Sources */, + 0250C1AD2C231E2500B9E554 /* ProfileCoreModel.xcdatamodeld in Sources */, 021D925228DC918D00ACC565 /* ProfileViewModel.swift in Sources */, + 022213CD2C0E050B00B917E6 /* CalendarSettings.swift in Sources */, + 022213D02C0E072400B917E6 /* ProfilePersistenceProtocol.swift in Sources */, 0248F9B128DDB09D0041327E /* Strings.swift in Sources */, 020306CA2932B14D000949EA /* EditProfileViewModel.swift in Sources */, 0259104829C3A5F0004B5A55 /* VideoQualityView.swift in Sources */, @@ -1675,6 +1707,19 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCVersionGroup section */ + 022213D32C0E092300B917E6 /* ProfileCoreModel.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + 022213D42C0E092300B917E6 /* ProfileCoreModel.xcdatamodel */, + ); + currentVersion = 022213D42C0E092300B917E6 /* ProfileCoreModel.xcdatamodel */; + path = ProfileCoreModel.xcdatamodeld; + sourceTree = ""; + versionGroupType = wrapper.xcdatamodel; + }; +/* End XCVersionGroup section */ }; rootObject = 020F834128DB4CCD0062FA70 /* Project object */; } diff --git a/Profile/Profile/Data/Network/ProfileEndpoint.swift b/Profile/Profile/Data/Network/ProfileEndpoint.swift index b17e5db6d..a72264ebd 100644 --- a/Profile/Profile/Data/Network/ProfileEndpoint.swift +++ b/Profile/Profile/Data/Network/ProfileEndpoint.swift @@ -16,10 +16,12 @@ enum ProfileEndpoint: EndPointType { case deleteProfilePicture(username: String) case logOut(refreshToken: String, clientID: String) case deleteAccount(password: String) + case enrollmentsStatus(username: String) + case getCourseDates(courseID: String) var path: String { switch self { - case .getUserProfile(let username): + case let .getUserProfile(username): return "/api/user/v1/accounts/\(username)" case .logOut: return "/oauth2/revoke_token/" @@ -27,16 +29,20 @@ enum ProfileEndpoint: EndPointType { return "/api/user/v1/accounts/\(username)" case let .uploadProfilePicture(username, _): return "/api/user/v1/accounts/\(username)/image" - case .deleteProfilePicture(username: let username): + case let .deleteProfilePicture(username): return "/api/user/v1/accounts/\(username)/image" case .deleteAccount: return "/api/user/v1/accounts/deactivate_logout/" + case let .enrollmentsStatus(username): + return "/api/mobile/v1/users/\(username)/enrollments_status/" + case let .getCourseDates(courseID): + return "/api/course_home/v1/dates/\(courseID)" } } var httpMethod: HTTPMethod { switch self { - case .getUserProfile: + case .getUserProfile, .enrollmentsStatus, .getCourseDates: return .get case .logOut: return .post @@ -82,11 +88,15 @@ enum ProfileEndpoint: EndPointType { "username": username ] return .requestParameters(parameters: params, encoding: JSONEncoding.default) - case .deleteAccount(password: let password): + case let .deleteAccount(password): let params: [String: String] = [ "password": password ] return .requestParameters(parameters: params, encoding: URLEncoding.httpBody) + case let .enrollmentsStatus(username): + return .requestParameters(parameters: nil, encoding: JSONEncoding.default) + case .getCourseDates: + return .requestParameters(encoding: JSONEncoding.default) } } } diff --git a/Profile/Profile/Data/Persistence/ProfileCoreModel.xcdatamodeld/ProfileCoreModel.xcdatamodel/contents b/Profile/Profile/Data/Persistence/ProfileCoreModel.xcdatamodeld/ProfileCoreModel.xcdatamodel/contents new file mode 100644 index 000000000..7ebe9bf79 --- /dev/null +++ b/Profile/Profile/Data/Persistence/ProfileCoreModel.xcdatamodeld/ProfileCoreModel.xcdatamodel/contents @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Profile/Profile/Data/Persistence/ProfilePersistenceProtocol.swift b/Profile/Profile/Data/Persistence/ProfilePersistenceProtocol.swift new file mode 100644 index 000000000..3e26799bd --- /dev/null +++ b/Profile/Profile/Data/Persistence/ProfilePersistenceProtocol.swift @@ -0,0 +1,39 @@ +// +// ProfilePersistenceProtocol.swift +// Profile +// +// Created by  Stepanok Ivan on 03.06.2024. +// + +import CoreData +import Core + +public protocol ProfilePersistenceProtocol { + func getCourseState(courseID: String) -> CourseCalendarState? + func getAllCourseStates() -> [CourseCalendarState] + func saveCourseState(state: CourseCalendarState) + func removeCourseState(courseID: String) + func deleteAllCourseStatesAndEvents() + func saveCourseCalendarEvent(_ event: CourseCalendarEvent) + func removeCourseCalendarEvents(for courseId: String) + func removeAllCourseCalendarEvents() + func getCourseCalendarEvents(for courseId: String) -> [CourseCalendarEvent] +} + +#if DEBUG +public struct ProfilePersistenceMock: ProfilePersistenceProtocol { + public func getCourseState(courseID: String) -> CourseCalendarState? { nil } + public func getAllCourseStates() -> [CourseCalendarState] {[]} + public func saveCourseState(state: CourseCalendarState) {} + public func removeCourseState(courseID: String) {} + public func deleteAllCourseStatesAndEvents() {} + public func saveCourseCalendarEvent(_ event: CourseCalendarEvent) {} + public func removeCourseCalendarEvents(for courseId: String) {} + public func removeAllCourseCalendarEvents() {} + public func getCourseCalendarEvents(for courseId: String) -> [CourseCalendarEvent] { [] } +} +#endif + +public final class ProfileBundle { + private init() {} +} diff --git a/Profile/Profile/Data/ProfileRepository.swift b/Profile/Profile/Data/ProfileRepository.swift index ffeca922d..2bd35cdfe 100644 --- a/Profile/Profile/Data/ProfileRepository.swift +++ b/Profile/Profile/Data/ProfileRepository.swift @@ -22,6 +22,8 @@ public protocol ProfileRepositoryProtocol { func deleteAccount(password: String) async throws -> Bool func getSettings() -> UserSettings func saveSettings(_ settings: UserSettings) + func enrollmentsStatus() async throws -> [CourseForSync] + func getCourseDates(courseID: String) async throws -> CourseDates } public class ProfileRepository: ProfileRepositoryProtocol { @@ -149,6 +151,20 @@ public class ProfileRepository: ProfileRepositoryProtocol { public func saveSettings(_ settings: UserSettings) { storage.userSettings = settings } + + public func enrollmentsStatus() async throws -> [CourseForSync] { + let username = storage.user?.username ?? "" + let result = try await api.requestData(ProfileEndpoint.enrollmentsStatus(username: username)) + .mapResponse(DataLayer.EnrollmentsStatus.self).domain + return result + } + + public func getCourseDates(courseID: String) async throws -> CourseDates { + let courseDates = try await api.requestData( + ProfileEndpoint.getCourseDates(courseID: courseID) + ).mapResponse(DataLayer.CourseDates.self).domain + return courseDates + } } // Mark - For testing and SwiftUI preview @@ -238,6 +254,38 @@ class ProfileRepositoryMock: ProfileRepositoryProtocol { return UserSettings(wifiOnly: true, streamingQuality: .auto, downloadQuality: .auto) } public func saveSettings(_ settings: UserSettings) {} + + public func enrollmentsStatus() async throws -> [CourseForSync] { + let result = [ + DataLayer.EnrollmentsStatusElement(courseID: "1", courseName: "Course 1", isActive: true), + DataLayer.EnrollmentsStatusElement(courseID: "2", courseName: "Course 2", isActive: false), + DataLayer.EnrollmentsStatusElement(courseID: "3", courseName: "Course 3", isActive: false), + DataLayer.EnrollmentsStatusElement(courseID: "4", courseName: "Course 4", isActive: true), + DataLayer.EnrollmentsStatusElement(courseID: "5", courseName: "Course 5", isActive: true), + DataLayer.EnrollmentsStatusElement(courseID: "6", courseName: "Course 6", isActive: false), + DataLayer.EnrollmentsStatusElement(courseID: "7", courseName: "Course 7", isActive: true), + DataLayer.EnrollmentsStatusElement(courseID: "8", courseName: "Course 8", isActive: true), + DataLayer.EnrollmentsStatusElement(courseID: "9", courseName: "Course 9", isActive: true), + ] + + return result.domain + } + + func getCourseDates(courseID: String) async throws -> CourseDates { + return CourseDates( + datesBannerInfo: DatesBannerInfo( + missedDeadlines: false, + contentTypeGatingEnabled: false, + missedGatedContent: false, + verifiedUpgradeLink: "", + status: .datesTabInfoBanner + ), + courseDateBlocks: [], + hasEnded: true, + learnerIsFullAccess: true, + userTimezone: nil + ) + } } // swiftlint:enable all #endif diff --git a/Profile/Profile/Data/ProfileStorage.swift b/Profile/Profile/Data/ProfileStorage.swift new file mode 100644 index 000000000..8110ada04 --- /dev/null +++ b/Profile/Profile/Data/ProfileStorage.swift @@ -0,0 +1,35 @@ +// +// ProfileStorage.swift +// Profile +// +// Created by  Stepanok Ivan on 30.08.2023. +// + +import Foundation +import Core +import UIKit + +public protocol ProfileStorage { + var userProfile: DataLayer.UserProfile? {get set} + var calendarSettings: CalendarSettings? {get set} + var hideInactiveCourses: Bool? {get set} + var lastLoginUsername: String? {get set} + var lastCalendarName: String? {get set} + var lastCalendarUpdateDate: Date? {get set} + var firstCalendarUpdate: Bool? {get set} +} + +#if DEBUG +public class ProfileStorageMock: ProfileStorage { + + public var userProfile: DataLayer.UserProfile? + public var calendarSettings: CalendarSettings? + public var hideInactiveCourses: Bool? + public var lastLoginUsername: String? + public var lastCalendarName: String? + public var lastCalendarUpdateDate: Date? + public var firstCalendarUpdate: Bool? + + public init() {} +} +#endif diff --git a/Profile/Profile/Domain/ProfileInteractor.swift b/Profile/Profile/Domain/ProfileInteractor.swift index 18e09aec2..bce7cf620 100644 --- a/Profile/Profile/Domain/ProfileInteractor.swift +++ b/Profile/Profile/Domain/ProfileInteractor.swift @@ -23,6 +23,8 @@ public protocol ProfileInteractorProtocol { func deleteAccount(password: String) async throws -> Bool func getSettings() -> UserSettings func saveSettings(_ settings: UserSettings) + func enrollmentsStatus() async throws -> [CourseForSync] + func getCourseDates(courseID: String) async throws -> CourseDates } public class ProfileInteractor: ProfileInteractorProtocol { @@ -80,6 +82,14 @@ public class ProfileInteractor: ProfileInteractorProtocol { public func saveSettings(_ settings: UserSettings) { return repository.saveSettings(settings) } + + public func enrollmentsStatus() async throws -> [CourseForSync] { + return try await repository.enrollmentsStatus() + } + + public func getCourseDates(courseID: String) async throws -> CourseDates { + return try await repository.getCourseDates(courseID: courseID) + } } // Mark - For testing and SwiftUI preview diff --git a/Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift b/Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift new file mode 100644 index 000000000..8e4a7ad22 --- /dev/null +++ b/Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift @@ -0,0 +1,415 @@ +// +// CalendarManager.swift +// Profile +// +// Created by  Stepanok Ivan on 12.06.2024. +// + +import SwiftUI +import Combine +import EventKit +import Theme +import BranchSDK +import CryptoKit +import Core + +// MARK: - CalendarManager +public class CalendarManager: CalendarManagerProtocol { + let eventStore = EKEventStore() + private let alertOffset = -1 + private var persistence: ProfilePersistenceProtocol + private var interactor: ProfileInteractorProtocol + private var profileStorage: ProfileStorage + + public init( + persistence: ProfilePersistenceProtocol, + interactor: ProfileInteractorProtocol, + profileStorage: ProfileStorage + ) { + self.persistence = persistence + self.interactor = interactor + self.profileStorage = profileStorage + } + + var authorizationStatus: EKAuthorizationStatus { + return EKEventStore.authorizationStatus(for: .event) + } + + var calendarName: String { + profileStorage.calendarSettings?.calendarName + ?? ProfileLocalization.Calendar.courseDates((Bundle.main.applicationName ?? "")) + } + + var colorSelection: DropDownPicker.DownPickerOption? { + .init( + color: DropDownColor( + rawValue: profileStorage.calendarSettings?.colorSelection ?? "" + ) ?? .accent + ) + } + + var calendarSource: EKSource? { + eventStore.refreshSourcesIfNecessary() + + let iCloud = eventStore.sources.first( + where: { $0.sourceType == .calDAV && $0.title.localizedCaseInsensitiveContains("icloud") }) + let local = eventStore.sources.first(where: { $0.sourceType == .local }) + let fallback = eventStore.defaultCalendarForNewEvents?.source + guard let accountSelection = profileStorage.calendarSettings?.accountSelection else { + return iCloud ?? local ?? fallback + } + switch accountSelection { + case ProfileLocalization.Calendar.Dropdown.icloud: + return iCloud ?? local ?? fallback + case ProfileLocalization.Calendar.Dropdown.local: + return fallback ?? local + default: + return iCloud ?? local ?? fallback + } + } + + var calendar: EKCalendar? { + eventStore.calendars(for: .event).first(where: { $0.title == calendarName }) + } + + public func courseStatus(courseID: String) -> SyncStatus { + let states = persistence.getAllCourseStates() + if states.contains(where: { $0.courseID == courseID }) { + return .synced + } else { + return .offline + } + } + + public func createCalendarIfNeeded() { + if eventStore.calendars(for: .event).first(where: { $0.title == calendarName }) == nil { + let calendar = EKCalendar(for: .event, eventStore: eventStore) + calendar.title = calendarName + + if let swiftUIColor = colorSelection?.color { + let uiColor = UIColor(swiftUIColor) + calendar.cgColor = uiColor.cgColor + } else { + calendar.cgColor = Theme.Colors.accentColor.cgColor + } + + calendar.source = calendarSource + do { + try eventStore.saveCalendar(calendar, commit: true) + } catch { + print(">>>> 🥷", error) + } + } + } + + public func isDatesChanged(courseID: String, checksum: String) -> Bool { + guard let oldState = persistence.getCourseState(courseID: courseID) else { return false } + return checksum != oldState.checksum + } + + public func syncCourse(courseID: String, courseName: String, dates: CourseDates) async { + createCalendarIfNeeded() + guard let calendar else { return } + if saveEvents(for: dates.dateBlocks, courseID: courseID, courseName: courseName, calendar: calendar) { + saveCourseDatesChecksum(courseID: courseID, checksum: dates.checksum) + } else { + debugLog("Failed to sync calendar for courseID: \(courseID)") + } + } + + public func removeOutdatedEvents(courseID: String) async { + let events = persistence.getCourseCalendarEvents(for: courseID) + for event in events { + deleteEventFromCalendar(eventIdentifier: event.eventIdentifier) + } + if var state = persistence.getCourseState(courseID: courseID) { + persistence.saveCourseState(state: CourseCalendarState(courseID: state.courseID, checksum: "")) + } + persistence.removeCourseCalendarEvents(for: courseID) + } + + func deleteEventFromCalendar(eventIdentifier: String) { + if let event = self.eventStore.event(withIdentifier: eventIdentifier) { + do { + try self.eventStore.remove(event, span: .thisEvent) + } catch let error { + debugLog("Failed to remove event: \(error)") + } + } + } + + @MainActor + public func requestAccess() async -> Bool { + await withCheckedContinuation { continuation in + eventStore.requestAccess(to: .event) { granted, _ in + if granted { + continuation.resume(returning: true) + } else { + continuation.resume(returning: false) + } + } + } + } + + public func clearAllData(removeCalendar: Bool) { + persistence.deleteAllCourseStatesAndEvents() + if removeCalendar { + removeOldCalendar() + } + profileStorage.firstCalendarUpdate = false + profileStorage.hideInactiveCourses = nil + profileStorage.lastCalendarName = nil + profileStorage.calendarSettings = nil + profileStorage.lastCalendarUpdateDate = nil + } + + private func saveCourseDatesChecksum(courseID: String, checksum: String) { + var states = persistence.getAllCourseStates() + states.append(CourseCalendarState(courseID: courseID, checksum: checksum)) + for state in states { + persistence.saveCourseState(state: state) + } + } + + private func saveEvents( + for dateBlocks: [Date: [CourseDateBlock]], + courseID: String, + courseName: String, + calendar: EKCalendar + ) -> Bool { + let events = generateEvents(for: dateBlocks, courseName: courseName, calendar: calendar) + var saveSuccessful = true + events.forEach { event in + if !eventExists(event, in: calendar) { + do { + try eventStore.save(event, span: .thisEvent) + persistence.saveCourseCalendarEvent( + CourseCalendarEvent(courseID: courseID, eventIdentifier: event.eventIdentifier) + ) + } catch { + saveSuccessful = false + } + } + } + return saveSuccessful + } + + private func eventExists(_ event: EKEvent, in calendar: EKCalendar) -> Bool { + let predicate = eventStore.predicateForEvents( + withStart: event.startDate, + end: event.endDate, + calendars: [calendar] + ) + let existingEvents = eventStore.events(matching: predicate) + + return existingEvents.contains { existingEvent in + existingEvent.title == event.title && + existingEvent.startDate == event.startDate && + existingEvent.endDate == event.endDate && + existingEvent.notes == event.notes + } + } + + public func filterCoursesBySelected(fetchedCourses: [CourseForSync]) async -> [CourseForSync] { + let courseCalendarStates = persistence.getAllCourseStates() + if !courseCalendarStates.isEmpty { + let coursesToDelete = courseCalendarStates.filter { course in + !fetchedCourses.contains { $0.courseID == course.courseID } + } + let inactiveCourses = fetchedCourses.filter { course in + courseCalendarStates.contains { $0.courseID == course.courseID } && !course.active + } + + for course in coursesToDelete { + await removeOutdatedEvents(courseID: course.courseID) + } + + for course in inactiveCourses { + await removeOutdatedEvents(courseID: course.courseID) + } + + let newlyActiveCourses = fetchedCourses.filter { fetchedCourse in + courseCalendarStates.contains { $0.courseID == fetchedCourse.courseID } && fetchedCourse.active + } + + return fetchedCourses.filter { course in + courseCalendarStates.contains { $0.courseID == course.courseID } && course.active + } + } else { + return fetchedCourses + } + } + + private func generateEvents( + for dateBlocks: [Date: [CourseDateBlock]], + courseName: String, + calendar: EKCalendar + ) -> [EKEvent] { + var events: [EKEvent] = [] + dateBlocks.forEach { item in + let blocks = item.value + if blocks.count > 1 { + if let generatedEvent = calendarEvent(for: blocks, courseName: courseName, calendar: calendar) { + events.append(generatedEvent) + } + } else { + if let block = blocks.first { + if let generatedEvent = calendarEvent(for: block, courseName: courseName, calendar: calendar) { + events.append(generatedEvent) + } + } + } + } + return events + } + + private func calendarEvent(for block: CourseDateBlock, courseName: String, calendar: EKCalendar) -> EKEvent? { + guard !block.title.isEmpty else { return nil } + + let title = block.title + let startDate = block.date.addingTimeInterval(Double(alertOffset) * 3600) + let secondAlert = startDate.addingTimeInterval(Double(alertOffset) * 86400) + let endDate = block.date + var notes = "\(calendar.title)\n\n\(block.title)" + + if let link = generateDeeplink(componentBlockID: block.firstComponentBlockID) { + notes += "\n\(link)" + } + + return generateEvent( + title: title, + startDate: startDate, + endDate: endDate, + secondAlert: secondAlert, + notes: notes, + location: courseName, + calendar: calendar + ) + } + + private func calendarEvent(for blocks: [CourseDateBlock], courseName: String, calendar: EKCalendar) -> EKEvent? { + guard let block = blocks.first, !block.title.isEmpty else { return nil } + + let title = block.title + let startDate = block.date.addingTimeInterval(Double(alertOffset) * 3600) + let secondAlert = startDate.addingTimeInterval(Double(alertOffset) * 86400) + let endDate = block.date + let notes = "\(calendar.title)\n\n" + blocks.compactMap { block -> String in + if let link = generateDeeplink(componentBlockID: block.firstComponentBlockID) { + return "\(block.title)\n\(link)" + } else { + return block.title + } + }.joined(separator: "\n\n") + + return generateEvent( + title: title, + startDate: startDate, + endDate: endDate, + secondAlert: secondAlert, + notes: notes, + location: courseName, + calendar: calendar + ) + } + + private func removeCalendar(for courseID: String, calendarName: String, completion: ((Bool) -> Void)? = nil) { + guard let calendar = localCalendar(for: courseID, calendarName: calendarName) else { completion?(true); return } + do { + try eventStore.removeCalendar(calendar, commit: true) + persistence.removeCourseCalendarEvents(for: courseID) + completion?(true) + } catch { + completion?(false) + } + } + + private func localCalendar(for courseID: String, calendarName: String) -> EKCalendar? { + if authorizationStatus != .authorized { return nil } + let calendarName = "\(calendarName) - \(courseID)" + var calendars = eventStore.calendars(for: .event).filter { $0.title == calendarName } + if calendars.isEmpty { + return nil + } else { + let calendar = calendars.removeLast() + calendars.forEach { try? eventStore.removeCalendar($0, commit: true) } + return calendar + } + } + + 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: profileStorage.calendarSettings?.calendarName ?? "", + CalendarDeepLinkKeys.componentID.rawValue: componentBlockID + ] + let metadata = BranchContentMetadata() + metadata.customMetadata = dictionary + branchUniversalObject.contentMetadata = metadata + let properties = BranchLinkProperties() + let shortUrl = branchUniversalObject.getShortUrl(with: properties) + return shortUrl + } + + private func generateEvent( + title: String, + startDate: Date, + endDate: Date, + secondAlert: Date, + notes: String, + location: String, + calendar: EKCalendar + ) -> EKEvent { + let event = EKEvent(eventStore: eventStore) + event.title = title + event.location = location + event.startDate = startDate + event.endDate = endDate + event.calendar = calendar + 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 + } + + public func removeOldCalendar() { + guard let lastCalendarName = profileStorage.lastCalendarName else { return } + if let oldCalendar = eventStore.calendars(for: .event).first(where: { $0.title == lastCalendarName }) { + do { + try eventStore.removeCalendar(oldCalendar, commit: true) + debugLog("Old calendar '\(lastCalendarName)' removed successfully") + } catch { + debugLog("Failed to remove old calendar '\(lastCalendarName)': \(error.localizedDescription)") + } + } else { + debugLog("Old calendar '\(lastCalendarName)' not found") + } + profileStorage.lastCalendarName = nil + } +} + +// MARK: - Enums and Constants + +enum CalendarDeepLinkType: String { + case courseComponent = "course_component" +} + +private enum CalendarDeepLinkKeys: String, RawStringExtractable { + case courseID = "course_id" + case screenName = "screen_name" + case componentID = "component_id" +} diff --git a/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift b/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift index 5ba8b2944..54e0341fa 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift @@ -84,47 +84,77 @@ public struct CoursesToSyncView: View { private var coursesList: some View { VStack(alignment: .leading, spacing: 24) { - ForEach( - Array( - viewModel.coursesForSync.filter({ course in - course.synced == viewModel.synced && (!viewModel.hideInactiveCourses || course.active) - }).enumerated() - ), - id: \.offset - ) { _, course in - HStack { - CheckBoxView( - checked: Binding( - get: { course.synced }, - set: { _ in viewModel.toggleSync(for: course) } - ), - text: course.name, - color: Theme.Colors.textPrimary.opacity(course.active ? 1 : 0.8) - ) - - if !course.active { - Text(ProfileLocalization.CoursesToSync.inactive) - .font(Theme.Fonts.labelSmall) - .foregroundStyle(Theme.Colors.textPrimary.opacity(0.8)) + if viewModel.coursesForSync.allSatisfy({ !$0.synced }) && viewModel.synced { + noSyncedCourses + } else { + ForEach( + Array( + viewModel.coursesForSync.filter({ course in + course.synced == viewModel.synced && (!viewModel.hideInactiveCourses || course.active) + }) + .sorted { $0.active && !$1.active } + .enumerated() + ), + id: \.offset + ) { _, course in + HStack { + CheckBoxView( + checked: Binding( + get: { course.synced }, + set: { _ in viewModel.toggleSync(for: course) } + ), + text: course.name, + color: Theme.Colors.textPrimary.opacity(course.active ? 1 : 0.8) + ) + + if !course.active { + Text(ProfileLocalization.CoursesToSync.inactive) + .font(Theme.Fonts.labelSmall) + .foregroundStyle(Theme.Colors.textPrimary.opacity(0.8)) + } } + .frame( + minWidth: 0, + maxWidth: .infinity, + alignment: .leading + ) } - .frame( - minWidth: 0, - maxWidth: .infinity, - alignment: .leading - ) } + Spacer(minLength: 100) } .padding(.horizontal, 24) .padding(.vertical, 16) } + + private var noSyncedCourses: some View { + VStack(spacing: 8) { + Spacer() + CoreAssets.learnEmpty.swiftUIImage + .resizable() + .frame(width: 96, height: 96) + .foregroundStyle(Theme.Colors.textSecondaryLight) + Text(ProfileLocalization.Sync.noSynced) + .foregroundStyle(Theme.Colors.textPrimary) + .font(Theme.Fonts.titleMedium) + Text(ProfileLocalization.Sync.noSyncedDescription) + .multilineTextAlignment(.center) + .foregroundStyle(Theme.Colors.textPrimary) + .font(Theme.Fonts.labelMedium) + .frame(width: 245) + } + } } #if DEBUG struct CoursesToSyncView_Previews: PreviewProvider { static var previews: some View { let vm = DatesAndCalendarViewModel( - router: ProfileRouterMock() + router: ProfileRouterMock(), + interactor: ProfileInteractor(repository: ProfileRepositoryMock()), + profileStorage: ProfileStorageMock(), + persistence: ProfilePersistenceMock(), + calendarManager: CalendarManagerMock(), + connectivity: Connectivity() ) return CoursesToSyncView(viewModel: vm) .previewDisplayName("Courses to Sync") diff --git a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift index a1ab29968..886909605 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift @@ -55,28 +55,45 @@ public struct DatesAndCalendarView: View { .navigationBarHidden(true) .navigationBarBackButtonHidden(true) - // Error Alert if needed - if viewModel.showError { - ErrorAlertView(errorMessage: $viewModel.errorMessage) - } if screenDimmed { Color.black.opacity(0.3) .ignoresSafeArea() .onTapGesture { viewModel.openNewCalendarView = false screenDimmed = false + viewModel.showCalendaAccessDenied = false + viewModel.calendarName = viewModel.oldCalendarName + viewModel.colorSelection = viewModel.oldColorSelection } } + + // Error Alert if needed + if viewModel.showError { + ErrorAlertView(errorMessage: $viewModel.errorMessage) + } + if viewModel.openNewCalendarView { NewCalendarView( title: .newCalendar, viewModel: viewModel, beginSyncingTapped: { + guard viewModel.isInternetAvaliable else { + viewModel.openNewCalendarView = false + screenDimmed = false + viewModel.calendarName = viewModel.oldCalendarName + viewModel.colorSelection = viewModel.oldColorSelection + return + } if viewModel.calendarName == "" { viewModel.calendarName = viewModel.calendarNameHint } - viewModel.router.showSyncCalendarOptions() }, + viewModel.saveCalendarOptions() + viewModel.router.back(animated: false) + viewModel.router.showSyncCalendarOptions() + }, onCloseTapped: { + viewModel.calendarName = viewModel.oldCalendarName + viewModel.colorSelection = viewModel.oldColorSelection viewModel.openNewCalendarView = false screenDimmed = false } @@ -88,16 +105,16 @@ public struct DatesAndCalendarView: View { } } - if viewModel.showCalendaAccessDenided { + if viewModel.showCalendaAccessDenied { CalendarDialogView( type: .calendarAccess, action: { - viewModel.showCalendaAccessDenided = false + viewModel.showCalendaAccessDenied = false screenDimmed = false viewModel.openAppSettings() }, onCloseTapped: { - viewModel.showCalendaAccessDenided = false + viewModel.showCalendaAccessDenied = false screenDimmed = false } ) @@ -138,9 +155,15 @@ public struct DatesAndCalendarView: View { .foregroundColor(Theme.Colors.textPrimary) .accessibilityIdentifier("calendar_sync_description") - StyledButton(ProfileLocalization.CalendarSync.button, action: { - viewModel.requestCalendarPermission() - }, horizontalPadding: true) + StyledButton( + ProfileLocalization.CalendarSync.button, + action: { + Task { + await viewModel.requestCalendarPermission() + } + }, + horizontalPadding: true + ) .fixedSize() .accessibilityIdentifier("calendar_sync_button") } @@ -185,11 +208,15 @@ public struct DatesAndCalendarView: View { struct DatesAndCalendarView_Previews: PreviewProvider { static var previews: some View { let vm = DatesAndCalendarViewModel( - router: ProfileRouterMock() + router: ProfileRouterMock(), + interactor: ProfileInteractor(repository: ProfileRepositoryMock()), + profileStorage: ProfileStorageMock(), + persistence: ProfilePersistenceMock(), + calendarManager: CalendarManagerMock(), + connectivity: Connectivity() ) DatesAndCalendarView(viewModel: vm) .loadFonts() } } #endif - diff --git a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift index a87dff139..560d18978 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift @@ -7,125 +7,430 @@ import SwiftUI import Combine -import Core import EventKit import Theme +import BranchSDK +import CryptoKit +import Core -public struct CourseForSync: Identifiable { - public let id: UUID - public let name: String - public var synced: Bool - public var active: Bool - - public init(id: UUID = UUID(), name: String, synced: Bool, active: Bool) { - self.id = id - self.name = name - self.synced = synced - self.active = active - } -} +// MARK: - DatesAndCalendarViewModel public class DatesAndCalendarViewModel: ObservableObject { - // Output @Published var useRelativeDates: Bool = false - @Published var showCalendaAccessDenided: Bool = false + @Published var showCalendaAccessDenied: Bool = false + @Published var showDisableCalendarSync: Bool = false @Published var showError: Bool = false - @Published var errorMessage: String? @Published var openNewCalendarView: Bool = false - // NewCalendarView @Published var accountSelection: DropDownPicker.DownPickerOption? = .init( title: ProfileLocalization.Calendar.Dropdown.icloud ) - var calendarNameHint: String @Published var calendarName: String = "" - @Published var colorSelection: DropDownPicker.DownPickerOption? = .init( - title: ProfileLocalization.Calendar.DropdownColor.accent, - color: Theme.Colors.accentColor - ) + @Published var oldCalendarName: String = "" + @Published var colorSelection: DropDownPicker.DownPickerOption? = .init(color: .accent) + @Published var oldColorSelection: DropDownPicker.DownPickerOption? = .init(color: .accent) - // SyncCalendarOptions @Published var assignmentStatus: AssignmentStatus = .synced - @Published var courseCalendarSync: Bool = false + @Published var courseCalendarSync: Bool = true @Published var reconnectRequired: Bool = false @Published var openChangeSyncView: Bool = false - - let accounts: [DropDownPicker.DownPickerOption] = [ + @Published var syncingCoursesCount: Int = 0 + + @Published var coursesForSync = [CourseForSync]() + + private var coursesForSyncBeforeChanges = [CourseForSync]() + + private var coursesForDeleting = [CourseForSync]() + private var coursesForAdding = [CourseForSync]() + + @Published var synced: Bool = true + @Published var hideInactiveCourses: Bool = false + + var errorMessage: String? { + didSet { + DispatchQueue.main.async { + withAnimation { + self.showError = self.errorMessage != nil + } + } + } + } + + private let accounts: [DropDownPicker.DownPickerOption] = [ .init(title: ProfileLocalization.Calendar.Dropdown.icloud), .init(title: ProfileLocalization.Calendar.Dropdown.local) ] let colors: [DropDownPicker.DownPickerOption] = [ - .init(title: ProfileLocalization.Calendar.DropdownColor.accent, color: Theme.Colors.accentColor), - .init(title: ProfileLocalization.Calendar.DropdownColor.red, color: .red), - .init(title: ProfileLocalization.Calendar.DropdownColor.orange, color: .orange), - .init(title: ProfileLocalization.Calendar.DropdownColor.yellow, color: .yellow), - .init(title: ProfileLocalization.Calendar.DropdownColor.green, color: .green), - .init(title: ProfileLocalization.Calendar.DropdownColor.blue, color: .blue), - .init(title: ProfileLocalization.Calendar.DropdownColor.purple, color: .purple), - .init(title: ProfileLocalization.Calendar.DropdownColor.brown, color: .brown) + .init(color: .accent), + .init(color: .red), + .init(color: .orange), + .init(color: .yellow), + .init(color: .green), + .init(color: .blue), + .init(color: .purple), + .init(color: .brown) ] var router: ProfileRouter + private var interactor: ProfileInteractorProtocol + private var profileStorage: ProfileStorage + private var persistence: ProfilePersistenceProtocol + private var calendarManager: CalendarManagerProtocol + private var connectivity: ConnectivityProtocol - // CoursesToSyncView - - @Published var coursesForSync = [ - CourseForSync(name: "History of Example Studies", synced: true, active: true), - CourseForSync(name: "Example Language 101", synced: true, active: true), - CourseForSync(name: "Example Course", synced: true, active: true), - CourseForSync(name: "More Example Courses", synced: true, active: true), - CourseForSync(name: "Another Example Course", synced: true, active: true), - CourseForSync(name: "Example Excluded Course", synced: false, active: false), - CourseForSync(name: "Science of Examples", synced: false, active: true), - CourseForSync(name: "Example Learning", synced: false, active: false), - CourseForSync(name: "Science of Examples", synced: false, active: false) - ] - - @Published var synced: Bool = true - @Published var hideInactiveCourses: Bool = false + private var cancellables = Set() + var calendarNameHint: String - func toggleSync(for course: CourseForSync) { - if let index = coursesForSync.firstIndex(where: { $0.id == course.id }) { - if coursesForSync[index].active { - coursesForSync[index].synced.toggle() - } - } - } - - public init(router: ProfileRouter) { + public init( + router: ProfileRouter, + interactor: ProfileInteractorProtocol, + profileStorage: ProfileStorage, + persistence: ProfilePersistenceProtocol, + calendarManager: CalendarManagerProtocol, + connectivity: ConnectivityProtocol + ) { self.router = router + self.interactor = interactor + self.profileStorage = profileStorage + self.persistence = persistence + self.calendarManager = calendarManager + self.connectivity = connectivity self.calendarNameHint = ProfileLocalization.Calendar.courseDates((Bundle.main.applicationName ?? "")) } + + @MainActor + var isInternetAvaliable: Bool { + let avaliable = connectivity.isInternetAvaliable + if !avaliable { + errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } + return avaliable + } + + // MARK: - Lifecycle Functions + + func loadCalendarOptions() { + guard let calendarSettings = profileStorage.calendarSettings else { return } + self.colorSelection = colors.first(where: { $0.colorString == calendarSettings.colorSelection }) + self.accountSelection = accounts.first(where: { $0.title == calendarSettings.accountSelection }) + self.oldCalendarName = profileStorage.lastCalendarName ?? calendarName + self.oldColorSelection = colorSelection + if let calendarName = calendarSettings.calendarName { + self.calendarName = calendarName + } + self.courseCalendarSync = calendarSettings.courseCalendarSync + self.hideInactiveCourses = profileStorage.hideInactiveCourses ?? false - // MARK: - Request Calendar Permission - func requestCalendarPermission() { - let eventStore = EKEventStore() - eventStore.requestAccess(to: .event) { [weak self] granted, error in - DispatchQueue.main.async { - if granted { - self?.showNewCalendarSetup() - } else { - self?.showCalendarAccessDenided() + $hideInactiveCourses + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] hide in + guard let self = self else { return } + self.profileStorage.hideInactiveCourses = hide + }) + .store(in: &cancellables) + + $courseCalendarSync + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] sync in + guard let self = self else { return } + if !sync { + Task { + await self.showDisableCalendarSync() + } + } + }) + .store(in: &cancellables) + + updateCoursesCount() + } + + func clearAllData() { + calendarManager.clearAllData(removeCalendar: true) + router.back(animated: false) + courseCalendarSync = true + showDisableCalendarSync = false + openNewCalendarView = false + router.showDatesAndCalendar() + } + + func deleteOrAddNewDatesIfNeeded() async { + if !coursesForDeleting.isEmpty { + await removeDeselectedCoursesFromCalendar() + } + if !coursesForAdding.isEmpty { + await fetchCourses() + } + } + + func saveCalendarOptions() { + if var calendarSettings = profileStorage.calendarSettings { + oldCalendarName = calendarName + oldColorSelection = colorSelection + calendarSettings.calendarName = calendarName + profileStorage.lastCalendarName = calendarName + + if let colorSelection, let colorString = colorSelection.colorString { + calendarSettings.colorSelection = colorString + } + + if let accountSelection = accountSelection?.title { + calendarSettings.accountSelection = accountSelection + } + + calendarSettings.courseCalendarSync = self.courseCalendarSync + profileStorage.calendarSettings = calendarSettings + } else { + if let colorSelection, + let colorString = colorSelection.colorString, + let accountSelection = accountSelection?.title { + profileStorage.calendarSettings = CalendarSettings( + colorSelection: colorString, + calendarName: calendarName, + accountSelection: accountSelection, + courseCalendarSync: self.courseCalendarSync, + useRelativeDates: self.useRelativeDates + ) + profileStorage.lastCalendarName = calendarName + } + } + } + + // MARK: - Fetch Courses and Sync + @MainActor + func fetchCourses() async { + guard connectivity.isInternetAvaliable else { return } + assignmentStatus = .loading + guard await calendarManager.requestAccess() else { + await showCalendarAccessDenied() + return + } + calendarManager.createCalendarIfNeeded() + do { + let fetchedCourses = try await interactor.enrollmentsStatus() + self.coursesForSync = fetchedCourses + let courseCalendarStates = persistence.getAllCourseStates() + if profileStorage.firstCalendarUpdate == false && courseCalendarStates.isEmpty { + await syncAllActiveCourses() + } else { + coursesForSync = coursesForSync.map { course in + var updatedCourse = course + updatedCourse.synced = courseCalendarStates.contains { + $0.courseID == course.courseID + } && course.active + return updatedCourse } + + let addingIDs = Set(coursesForAdding.map { $0.courseID }) + + coursesForSync = coursesForSync.map { course in + var updatedCourse = course + if addingIDs.contains(course.courseID) { + updatedCourse.synced = true + } + return updatedCourse + } + + for course in coursesForSync.filter { $0.synced } { + do { + let courseDates = try await interactor.getCourseDates(courseID: course.courseID) + await syncSelectedCourse( + courseID: course.courseID, + courseName: course.name, + courseDates: courseDates, + active: course.active + ) + } catch { + assignmentStatus = .failed + } + } + coursesForAdding = [] + profileStorage.firstCalendarUpdate = true + updateCoursesCount() } + assignmentStatus = .synced + } catch { + self.assignmentStatus = .failed + debugLog("Error fetching courses: \(error)") } } - func openAppSettings() { - if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url, options: [:], completionHandler: nil) + private func updateCoursesCount() { + syncingCoursesCount = coursesForSync.filter { $0.active && $0.synced }.count + } + + @MainActor + private func syncAllActiveCourses() async { + guard profileStorage.firstCalendarUpdate == false else { + coursesForAdding = [] + coursesForSyncBeforeChanges = [] + assignmentStatus = .synced + updateCoursesCount() + return } + let selectedCourses = await calendarManager.filterCoursesBySelected(fetchedCourses: coursesForSync) + let activeSelectedCourses = selectedCourses.filter { $0.active } + assignmentStatus = .loading + for course in activeSelectedCourses { + do { + let courseDates = try await interactor.getCourseDates(courseID: course.courseID) + await syncSelectedCourse( + courseID: course.courseID, + courseName: course.name, + courseDates: courseDates, + active: course.active + ) + } catch { + assignmentStatus = .failed + } + } + profileStorage.firstCalendarUpdate = true + coursesForAdding = [] + coursesForSyncBeforeChanges = [] + assignmentStatus = .synced + updateCoursesCount() } - private func showCalendarAccessDenided() { - withAnimation(.bouncy(duration: 0.3)) { - self.showCalendaAccessDenided = true + private func filterCoursesBySynced() -> [CourseForSync] { + let syncedCourses = coursesForSync.filter { $0.synced && $0.active } + return syncedCourses + } + + func deleteOldCalendarIfNeeded() async { + guard let calSettings = profileStorage.calendarSettings else { return } + let courseCalendarStates = persistence.getAllCourseStates() + let courseCountChanges = courseCalendarStates.count != coursesForSync.count + let nameChanged = oldCalendarName != calendarName + let colorChanged = colorSelection != colors.first(where: { $0.colorString == calSettings.colorSelection }) + let accountChanged = accountSelection != accounts.first(where: { $0.title == calSettings.accountSelection }) + + guard nameChanged || colorChanged || accountChanged || courseCountChanges else { return } + + calendarManager.removeOldCalendar() + saveCalendarOptions() + persistence.removeAllCourseCalendarEvents() + await fetchCourses() + } + + private func syncSelectedCourse( + courseID: String, + courseName: String, + courseDates: CourseDates, + active: Bool + ) async { + await MainActor.run { + self.assignmentStatus = .loading + } + + await calendarManager.removeOutdatedEvents(courseID: courseID) + guard active else { + await MainActor.run { + self.assignmentStatus = .synced + } + return + } + + await calendarManager.syncCourse(courseID: courseID, courseName: courseName, dates: courseDates) + if let index = self.coursesForSync.firstIndex(where: { $0.courseID == courseID && $0.active }) { + await MainActor.run { + self.coursesForSync[index].synced = true + } + } + await MainActor.run { + self.assignmentStatus = .synced + } + } + + @MainActor + func removeDeselectedCoursesFromCalendar() async { + for course in coursesForDeleting { + await calendarManager.removeOutdatedEvents(courseID: course.courseID) + persistence.removeCourseState(courseID: course.courseID) + persistence.removeCourseCalendarEvents(for: course.courseID) + if let index = self.coursesForSync.firstIndex(where: { $0.courseID == course.courseID }) { + self.coursesForSync[index].synced = false + } + } + updateCoursesCount() + coursesForDeleting = [] + coursesForSyncBeforeChanges = [] + } + + func toggleSync(for course: CourseForSync) { + guard course.active else { return } + if coursesForSyncBeforeChanges.isEmpty { + coursesForSyncBeforeChanges = coursesForSync + } + if let index = coursesForSync.firstIndex(where: { $0.courseID == course.courseID }) { + coursesForSync[index].synced.toggle() + updateCoursesForSyncAndDeletion(course: coursesForSync[index]) + } + } + + private func updateCoursesForSyncAndDeletion(course: CourseForSync) { + guard let initialCourse = coursesForSyncBeforeChanges.first(where: { + $0.courseID == course.courseID + }) else { return } + + if course.synced != initialCourse.synced { + if course.synced { + if !coursesForAdding.contains(where: { $0.courseID == course.courseID }) { + coursesForAdding.append(course) + } + if let index = coursesForDeleting.firstIndex(where: { $0.courseID == course.courseID }) { + coursesForDeleting.remove(at: index) + } + } else { + if !coursesForDeleting.contains(where: { $0.courseID == course.courseID }) { + coursesForDeleting.append(course) + } + if let index = coursesForAdding.firstIndex(where: { $0.courseID == course.courseID }) { + coursesForAdding.remove(at: index) + } + } + } else { + if let index = coursesForAdding.firstIndex(where: { $0.courseID == course.courseID }) { + coursesForAdding.remove(at: index) + } + if let index = coursesForDeleting.firstIndex(where: { $0.courseID == course.courseID }) { + coursesForDeleting.remove(at: index) + } + } + } + + // MARK: - Request Calendar Permission + @MainActor + func requestCalendarPermission() async { + if await calendarManager.requestAccess() { + await showNewCalendarSetup() + } else { + await showCalendarAccessDenied() } } - private func showNewCalendarSetup() { + @MainActor + private func showCalendarAccessDenied() async { + withAnimation(.bouncy(duration: 0.3)) { + self.showCalendaAccessDenied = true + } + } + + @MainActor + private func showDisableCalendarSync() async { withAnimation(.bouncy(duration: 0.3)) { - openNewCalendarView = true + self.showDisableCalendarSync = true + } + } + + @MainActor + private func showNewCalendarSetup() async { + withAnimation(.bouncy(duration: 0.3)) { + self.openNewCalendarView = true + } + } + + func openAppSettings() { + if let url = URL(string: UIApplication.openSettingsURLString), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url, options: [:], completionHandler: nil) } } } diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Elements/AssignmentStatusView.swift b/Profile/Profile/Presentation/DatesAndCalendar/Elements/AssignmentStatusView.swift index b1451a997..ace87a64c 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/Elements/AssignmentStatusView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/Elements/AssignmentStatusView.swift @@ -13,6 +13,7 @@ enum AssignmentStatus { case synced case failed case offline + case loading var statusText: String { switch self { @@ -22,10 +23,12 @@ enum AssignmentStatus { ProfileLocalization.AssignmentStatus.failed case .offline: ProfileLocalization.AssignmentStatus.offline + case .loading: + ProfileLocalization.AssignmentStatus.syncing } } - var image: Image { + var image: Image? { switch self { case .synced: CoreAssets.synced.swiftUIImage @@ -33,6 +36,8 @@ enum AssignmentStatus { CoreAssets.syncFailed.swiftUIImage case .offline: CoreAssets.syncOffline.swiftUIImage + case .loading: + nil } } } @@ -67,8 +72,12 @@ struct AssignmentStatusView: View { .multilineTextAlignment(.leading) Spacer() status.image + if status == .loading { + ProgressView() + .progressViewStyle(CircularProgressViewStyle()) + } } - + .frame(height: 52) .padding(.horizontal, 16) } .background( @@ -82,9 +91,9 @@ struct AssignmentStatusView: View { #Preview { AssignmentStatusView( title: "My Assignments", - status: .constant(.synced), + status: .constant(.loading), calendarColor: .blue ) - .loadFonts() + .loadFonts() } #endif diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Elements/CalendarDialogView.swift b/Profile/Profile/Presentation/DatesAndCalendar/Elements/CalendarDialogView.swift index c8a6b88db..cd5e89526 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/Elements/CalendarDialogView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/Elements/CalendarDialogView.swift @@ -13,7 +13,7 @@ struct CalendarDialogView: View { enum CalendarDialogType { case calendarAccess - case disableCalendarSync + case disableCalendarSync(calendarName: String) var title: String { switch self { @@ -28,8 +28,8 @@ struct CalendarDialogView: View { switch self { case .calendarAccess: ProfileLocalization.CalendarDialog.calendarAccessDescription - case .disableCalendarSync: - ProfileLocalization.CalendarDialog.disableCalendarSyncDescription + case .disableCalendarSync(let calendarName): + ProfileLocalization.CalendarDialog.disableCalendarSyncDescription(calendarName) } } } @@ -176,9 +176,9 @@ struct CalendarDialogView: View { #if DEBUG #Preview { CalendarDialogView( - type: .calendarAccess, - calendarCircleColor: .blue, - calendarName: "My Assignments", + type: .disableCalendarSync(calendarName: "Demo Calendar"), + calendarCircleColor: .red, + calendarName: "Demo Calendar", action: {}, onCloseTapped: {} ) diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Elements/DropDownPicker.swift b/Profile/Profile/Presentation/DatesAndCalendar/Elements/DropDownPicker.swift index bef40545b..73fe0a39a 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/Elements/DropDownPicker.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/Elements/DropDownPicker.swift @@ -9,20 +9,81 @@ import SwiftUI import Core import Theme -enum DropDownPickerState { +public enum DropDownPickerState { case top case bottom } -struct DropDownPicker: View { +public enum DropDownColor: String { + case accent + case red + case orange + case yellow + case green + case blue + case purple + case brown + + var title: String { + switch self { + case .accent: + ProfileLocalization.Calendar.DropdownColor.accent + case .red: + ProfileLocalization.Calendar.DropdownColor.red + case .orange: + ProfileLocalization.Calendar.DropdownColor.orange + case .yellow: + ProfileLocalization.Calendar.DropdownColor.yellow + case .green: + ProfileLocalization.Calendar.DropdownColor.green + case .blue: + ProfileLocalization.Calendar.DropdownColor.blue + case .purple: + ProfileLocalization.Calendar.DropdownColor.purple + case .brown: + ProfileLocalization.Calendar.DropdownColor.brown + } + } + var color: Color { + switch self { + case .accent: + .accentColor + case .red: + .red + case .orange: + .orange + case .yellow: + .yellow + case .green: + .green + case .blue: + .blue + case .purple: + .purple + case .brown: + .brown + } + } +} + +struct DropDownPicker: View { + struct DownPickerOption: Hashable { let title: String let color: Color? + let colorString: String? - init(title: String, color: Color? = nil) { + init(title: String) { self.title = title - self.color = color + self.color = nil + self.colorString = nil + } + + init(color: DropDownColor) { + self.title = color.title + self.color = color.color + self.colorString = color.rawValue } func hash(into hasher: inout Hasher) { @@ -136,8 +197,6 @@ struct DropDownPicker: View { .font(Theme.Fonts.bodyMedium) .foregroundStyle(Theme.Colors.textPrimary) Spacer() -// Image(systemName: "checkmark") -// .opacity(selection == option ? 1 : 0) } VStack { Spacer() diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift b/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift index 264914c75..9d8b9dd70 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/Elements/NewCalendarView.swift @@ -8,6 +8,7 @@ import SwiftUI import Core import Theme +import Combine struct NewCalendarView: View { @@ -30,6 +31,7 @@ struct NewCalendarView: View { @Environment(\.isHorizontal) private var isHorizontal private var beginSyncingTapped: (() -> Void) = {} private var onCloseTapped: (() -> Void) = {} + @State private var calendarName: String = "" private let title: Title @@ -58,6 +60,9 @@ struct NewCalendarView: View { content } } + .onAppear { + calendarName = viewModel.calendarName + } } private var content: some View { @@ -76,14 +81,14 @@ struct NewCalendarView: View { }) } .padding(.bottom, 20) - Text(ProfileLocalization.Calendar.account) - .font(Theme.Fonts.bodySmall).bold() - DropDownPicker(selection: $viewModel.accountSelection, state: .bottom, options: viewModel.accounts) Text(ProfileLocalization.Calendar.calendarName) .font(Theme.Fonts.bodySmall).bold() .padding(.top, 16) - TextField(viewModel.calendarNameHint, text: $viewModel.calendarName) + TextField(viewModel.calendarNameHint, text: $calendarName) + .onReceive(Just(calendarName), perform: { _ in + limitText(40) + }) .font(Theme.Fonts.bodyLarge) .padding() .background(Theme.Colors.background) @@ -103,13 +108,14 @@ struct NewCalendarView: View { Text(ProfileLocalization.Calendar.upcomingAssignments) .font(Theme.Fonts.bodySmall) .foregroundColor(Theme.Colors.textPrimary) - .padding(.vertical, 16) + .padding(.vertical, 13) .multilineTextAlignment(.center) .frame( minWidth: 0, maxWidth: .infinity, alignment: .center ) + .frame(height: 65) VStack(spacing: 16) { StyledButton( @@ -123,6 +129,7 @@ struct NewCalendarView: View { ) StyledButton(ProfileLocalization.Calendar.beginSyncing) { + viewModel.calendarName = calendarName beginSyncingTapped() } } @@ -140,14 +147,30 @@ struct NewCalendarView: View { ) .padding(24) } + + func limitText(_ upper: Int) { + if calendarName.count > upper { + calendarName = String(calendarName.prefix(upper)) + } + } } #if DEBUG #Preview { NewCalendarView( - title: .newCalendar, - viewModel: DatesAndCalendarViewModel(router: ProfileRouterMock()), - beginSyncingTapped: {}, + title: .changeSyncOptions, + viewModel: DatesAndCalendarViewModel( + router: ProfileRouterMock(), + interactor: ProfileInteractor( + repository: ProfileRepositoryMock() + ), + profileStorage: ProfileStorageMock(), + persistence: ProfilePersistenceMock(), + calendarManager: CalendarManagerMock(), + connectivity: Connectivity() + ), + beginSyncingTapped: { + }, onCloseTapped: {} ) .loadFonts() diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Models/CalendarSettings.swift b/Profile/Profile/Presentation/DatesAndCalendar/Models/CalendarSettings.swift new file mode 100644 index 000000000..7c1970d0c --- /dev/null +++ b/Profile/Profile/Presentation/DatesAndCalendar/Models/CalendarSettings.swift @@ -0,0 +1,56 @@ +// +// CalendarSettings.swift +// Profile +// +// Created by  Stepanok Ivan on 03.06.2024. +// + +import Foundation + +public struct CalendarSettings: Codable { + public var colorSelection: String + public var calendarName: String? + public var accountSelection: String + public var courseCalendarSync: Bool + public var useRelativeDates: Bool + + public init( + colorSelection: String, + calendarName: String?, + accountSelection: String, + courseCalendarSync: Bool, + useRelativeDates: Bool + ) { + self.colorSelection = colorSelection + self.calendarName = calendarName + self.accountSelection = accountSelection + self.courseCalendarSync = courseCalendarSync + self.useRelativeDates = useRelativeDates + } + + enum CodingKeys: String, CodingKey { + case colorSelection + case calendarName + case accountSelection + case courseCalendarSync + case useRelativeDates + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.colorSelection = try container.decode(String.self, forKey: .colorSelection) + self.calendarName = try container.decode(String.self, forKey: .calendarName) + self.accountSelection = try container.decode(String.self, forKey: .accountSelection) + self.courseCalendarSync = try container.decode(Bool.self, forKey: .courseCalendarSync) + self.useRelativeDates = try container.decode(Bool.self, forKey: .useRelativeDates) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(colorSelection, forKey: .colorSelection) + try container.encode(calendarName, forKey: .calendarName) + try container.encode(accountSelection, forKey: .accountSelection) + try container.encode(courseCalendarSync, forKey: .courseCalendarSync) + try container.encode(useRelativeDates, forKey: .useRelativeDates) + } +} diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Models/CourseCalendarEvent.swift b/Profile/Profile/Presentation/DatesAndCalendar/Models/CourseCalendarEvent.swift new file mode 100644 index 000000000..3ebe6c737 --- /dev/null +++ b/Profile/Profile/Presentation/DatesAndCalendar/Models/CourseCalendarEvent.swift @@ -0,0 +1,18 @@ +// +// CourseCalendarEvent.swift +// Profile +// +// Created by  Stepanok Ivan on 10.06.2024. +// + +import Foundation + +public struct CourseCalendarEvent { + public let courseID: String + public let eventIdentifier: String + + public init(courseID: String, eventIdentifier: String) { + self.courseID = courseID + self.eventIdentifier = eventIdentifier + } +} diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Models/CourseCalendarState.swift b/Profile/Profile/Presentation/DatesAndCalendar/Models/CourseCalendarState.swift new file mode 100644 index 000000000..4bdfa2310 --- /dev/null +++ b/Profile/Profile/Presentation/DatesAndCalendar/Models/CourseCalendarState.swift @@ -0,0 +1,18 @@ +// +// CourseCalendarState.swift +// Profile +// +// Created by  Stepanok Ivan on 03.06.2024. +// + +import Foundation + +public struct CourseCalendarState { + public let courseID: String + public var checksum: String + + public init(courseID: String, checksum: String) { + self.courseID = courseID + self.checksum = checksum + } +} diff --git a/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift b/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift index ecb0f213d..82414e263 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift @@ -72,7 +72,7 @@ public struct SyncCalendarOptionsView: View { screenDimmed = true withAnimation(.bouncy(duration: 0.3)) { if viewModel.reconnectRequired { - viewModel.showCalendaAccessDenided = true + viewModel.showCalendaAccessDenied = true } else { viewModel.openChangeSyncView = true } @@ -95,7 +95,7 @@ public struct SyncCalendarOptionsView: View { coursesToSync .padding(.bottom, 24) } - relativeDatesToggle +// relativeDatesToggle } .padding(.horizontal, isHorizontal ? 48 : 0) .frameLimit(width: proxy.size.width) @@ -106,51 +106,107 @@ public struct SyncCalendarOptionsView: View { .navigationBarHidden(true) .navigationBarBackButtonHidden(true) - // Error Alert if needed - if viewModel.showError { - ErrorAlertView(errorMessage: $viewModel.errorMessage) - } if screenDimmed { Color.black.opacity(0.3) .ignoresSafeArea() .onTapGesture { viewModel.openChangeSyncView = false - viewModel.showCalendaAccessDenided = false + viewModel.showCalendaAccessDenied = false + viewModel.showDisableCalendarSync = false + viewModel.courseCalendarSync = true screenDimmed = false + viewModel.calendarName = viewModel.oldCalendarName + viewModel.colorSelection = viewModel.oldColorSelection } } + + // Error Alert if needed + if viewModel.showError { + ErrorAlertView(errorMessage: $viewModel.errorMessage) + } + if viewModel.openChangeSyncView { NewCalendarView( title: .changeSyncOptions, viewModel: viewModel, - beginSyncingTapped: {}, + beginSyncingTapped: { + viewModel.openChangeSyncView = false + screenDimmed = false + + guard viewModel.isInternetAvaliable else { + viewModel.calendarName = viewModel.oldCalendarName + viewModel.colorSelection = viewModel.oldColorSelection + return + } + + Task { + await viewModel.deleteOldCalendarIfNeeded() + } + }, onCloseTapped: { viewModel.openChangeSyncView = false screenDimmed = false + viewModel.calendarName = viewModel.oldCalendarName + viewModel.colorSelection = viewModel.oldColorSelection } ) .transition(.move(edge: .bottom)) .frame(alignment: .center) - } else if viewModel.showCalendaAccessDenided { + } else if viewModel.showCalendaAccessDenied { CalendarDialogView( type: .calendarAccess, action: { - viewModel.showCalendaAccessDenided = false + viewModel.showCalendaAccessDenied = false screenDimmed = false viewModel.openAppSettings() }, onCloseTapped: { - viewModel.showCalendaAccessDenided = false + viewModel.showCalendaAccessDenied = false screenDimmed = false } ) .transition(.move(edge: .bottom)) .frame(alignment: .center) + .onAppear { + screenDimmed = true + } + } else if viewModel.showDisableCalendarSync { + CalendarDialogView( + type: .disableCalendarSync(calendarName: viewModel.calendarName), + calendarCircleColor: viewModel.colorSelection?.color, + calendarName: viewModel.calendarName, + action: { + viewModel.clearAllData() + }, + onCloseTapped: { + viewModel.showDisableCalendarSync = false + screenDimmed = false + viewModel.courseCalendarSync = true + } + ) + .transition(.move(edge: .bottom)) + .frame(alignment: .center) } } .ignoresSafeArea(.all, edges: .horizontal) } + .onFirstAppear { + Task { + await viewModel.fetchCourses() + } + } + .onChange(of: viewModel.courseCalendarSync) { sync in + if !sync { + screenDimmed = true + } + } + .onAppear { + viewModel.loadCalendarOptions() + Task { + await viewModel.deleteOrAddNewDatesIfNeeded() + } + } } // MARK: - Options Title @@ -175,6 +231,7 @@ public struct SyncCalendarOptionsView: View { VStack(alignment: .leading, spacing: 27) { Button(action: { // viewModel.trackProfileVideoSettingsClicked() + guard viewModel.isInternetAvaliable else { return } viewModel.router.showCoursesToSync() }, label: { @@ -182,7 +239,7 @@ public struct SyncCalendarOptionsView: View { Text( String( format: ProfileLocalization.CoursesToSync.syncingCourses( - viewModel.coursesForSync.count + viewModel.syncingCoursesCount ) ) ) @@ -222,7 +279,12 @@ public struct SyncCalendarOptionsView: View { struct SyncCalendarOptionsView_Previews: PreviewProvider { static var previews: some View { let vm = DatesAndCalendarViewModel( - router: ProfileRouterMock() + router: ProfileRouterMock(), + interactor: ProfileInteractor(repository: ProfileRepositoryMock()), + profileStorage: ProfileStorageMock(), + persistence: ProfilePersistenceMock(), + calendarManager: CalendarManagerMock(), + connectivity: Connectivity() ) SyncCalendarOptionsView(viewModel: vm) .loadFonts() diff --git a/Profile/Profile/SwiftGen/Strings.swift b/Profile/Profile/SwiftGen/Strings.swift index 0cf331373..d83527f97 100644 --- a/Profile/Profile/SwiftGen/Strings.swift +++ b/Profile/Profile/SwiftGen/Strings.swift @@ -60,6 +60,8 @@ public enum ProfileLocalization { public static let offline = ProfileLocalization.tr("Localizable", "ASSIGNMENT_STATUS.OFFLINE", fallback: "Offline") /// Synced public static let synced = ProfileLocalization.tr("Localizable", "ASSIGNMENT_STATUS.SYNCED", fallback: "Synced") + /// Syncing to calendar... + public static let syncing = ProfileLocalization.tr("Localizable", "ASSIGNMENT_STATUS.SYNCING", fallback: "Syncing to calendar...") } public enum Calendar { /// Account @@ -114,10 +116,12 @@ public enum ProfileLocalization { public static let calendarAccessDescription = ProfileLocalization.tr("Localizable", "CALENDAR_DIALOG.CALENDAR_ACCESS_DESCRIPTION", fallback: "To show upcoming assignments and course milestones on your calendar, we need permission to access your calendar.") /// Cancel public static let cancel = ProfileLocalization.tr("Localizable", "CALENDAR_DIALOG.CANCEL", fallback: "Cancel") - /// Change Sync Options - public static let disableCalendarSync = ProfileLocalization.tr("Localizable", "CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC", fallback: "Change Sync Options") - /// Disabling calendar sync will delete the calendar “My Assignments.” You can turn calendar sync back on at any time. - public static let disableCalendarSyncDescription = ProfileLocalization.tr("Localizable", "CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC_DESCRIPTION", fallback: "Disabling calendar sync will delete the calendar “My Assignments.” You can turn calendar sync back on at any time.") + /// Disable Calendar Sync + public static let disableCalendarSync = ProfileLocalization.tr("Localizable", "CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC", fallback: "Disable Calendar Sync") + /// Disabling calendar sync will delete the calendar “%@”. You can turn calendar sync back on at any time. + public static func disableCalendarSyncDescription(_ p1: Any) -> String { + return ProfileLocalization.tr("Localizable", "CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC_DESCRIPTION", String(describing: p1), fallback: "Disabling calendar sync will delete the calendar “%@”. You can turn calendar sync back on at any time.") + } /// Disable Syncing public static let disableSyncing = ProfileLocalization.tr("Localizable", "CALENDAR_DIALOG.DISABLE_SYNCING", fallback: "Disable Syncing") /// Grant Calendar Access @@ -277,11 +281,17 @@ public enum ProfileLocalization { /// Wi-fi only download public static let wifiTitle = ProfileLocalization.tr("Localizable", "SETTINGS.WIFI_TITLE", fallback: "Wi-fi only download") } + public enum Sync { + /// No Synced Courses + public static let noSynced = ProfileLocalization.tr("Localizable", "SYNC.NO_SYNCED", fallback: "No Synced Courses") + /// No courses are currently being synced to your calendar. + public static let noSyncedDescription = ProfileLocalization.tr("Localizable", "SYNC.NO_SYNCED_DESCRIPTION", fallback: "No courses are currently being synced to your calendar.") + } public enum SyncSelector { /// Not Synced public static let notSynced = ProfileLocalization.tr("Localizable", "SYNC_SELECTOR.NOT_SYNCED", fallback: "Not Synced") - /// Synced - public static let synced = ProfileLocalization.tr("Localizable", "SYNC_SELECTOR.SYNCED", fallback: "Synced") + /// To Sync + public static let synced = ProfileLocalization.tr("Localizable", "SYNC_SELECTOR.SYNCED", fallback: "To Sync") } public enum UnsavedDataAlert { /// Changes you have made will be discarded. diff --git a/Profile/Profile/en.lproj/Localizable.strings b/Profile/Profile/en.lproj/Localizable.strings index 9b6872159..d94e1152c 100644 --- a/Profile/Profile/en.lproj/Localizable.strings +++ b/Profile/Profile/en.lproj/Localizable.strings @@ -95,11 +95,12 @@ "ASSIGNMENT_STATUS.SYNCED" = "Synced"; "ASSIGNMENT_STATUS.FAILED" = "Sync Failed"; "ASSIGNMENT_STATUS.OFFLINE" = "Offline"; +"ASSIGNMENT_STATUS.SYNCING" = "Syncing to calendar..."; "CALENDAR_DIALOG.CALENDAR_ACCESS" = "Calendar Access"; -"CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC" = "Change Sync Options"; +"CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC" = "Disable Calendar Sync"; "CALENDAR_DIALOG.CALENDAR_ACCESS_DESCRIPTION" = "To show upcoming assignments and course milestones on your calendar, we need permission to access your calendar."; -"CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC_DESCRIPTION" = "Disabling calendar sync will delete the calendar “My Assignments.” You can turn calendar sync back on at any time."; +"CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC_DESCRIPTION" = "Disabling calendar sync will delete the calendar “%@”. You can turn calendar sync back on at any time."; "CALENDAR_DIALOG.GRANT_CALENDAR_ACCESS" = "Grant Calendar Access"; "CALENDAR_DIALOG.DISABLE_SYNCING" = "Disable Syncing"; "CALENDAR_DIALOG.CANCEL" = "Cancel"; @@ -145,5 +146,8 @@ "DROP_DOWN_PICKER.SELECT" = "Select"; -"SYNC_SELECTOR.SYNCED" = "Synced"; +"SYNC_SELECTOR.SYNCED" = "To Sync"; "SYNC_SELECTOR.NOT_SYNCED" = "Not Synced"; + +"SYNC.NO_SYNCED" = "No Synced Courses"; +"SYNC.NO_SYNCED_DESCRIPTION" = "No courses are currently being synced to your calendar."; diff --git a/Profile/Profile/uk.lproj/Localizable.strings b/Profile/Profile/uk.lproj/Localizable.strings index a8590f765..da932c17e 100644 --- a/Profile/Profile/uk.lproj/Localizable.strings +++ b/Profile/Profile/uk.lproj/Localizable.strings @@ -95,9 +95,9 @@ "ASSIGNMENT_STATUS.OFFLINE" = "Офлайн"; "CALENDAR_DIALOG.CALENDAR_ACCESS" = "Доступ до календаря"; -"CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC" = "Змінити параметри синхронізації"; +"CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC" = "Cкасувати синхронізацію календаря"; "CALENDAR_DIALOG.CALENDAR_ACCESS_DESCRIPTION" = "Щоб показати майбутні завдання та віхи курсу у вашому календарі, нам потрібен дозвіл на доступ до вашого календаря."; -"CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC_DESCRIPTION" = "Вимкнення синхронізації календаря видалить календар “Мої завдання”. Ви можете знову увімкнути синхронізацію календаря в будь-який час."; +"CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC_DESCRIPTION" = "Вимкнення синхронізації календаря видалить календар “%@”. Ви можете знову увімкнути синхронізацію календаря в будь-який час."; "CALENDAR_DIALOG.GRANT_CALENDAR_ACCESS" = "Надати доступ до календаря"; "CALENDAR_DIALOG.DISABLE_SYNCING" = "Вимкнути синхронізацію"; "CALENDAR_DIALOG.CANCEL" = "Скасувати"; @@ -143,3 +143,6 @@ "CALENDAR.COURSE_DATES" = "%@ Дати курсу"; "DROP_DOWN_PICKER.SELECT" = "Оберіть"; + +"SYNC.NO_SYNCED" = "Немає синхронізованих курсів"; +"SYNC.NO_SYNCED_DESCRIPTION" = "Жоден курс зараз не синхронізується з вашим календарем."; diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 1817697a4..534a1dde5 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -2738,6 +2738,38 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { perform?(`settings`) } + open func enrollmentsStatus() throws -> [CourseForSync] { + addInvocation(.m_enrollmentsStatus) + let perform = methodPerformValue(.m_enrollmentsStatus) as? () -> Void + perform?() + var __value: [CourseForSync] + do { + __value = try methodReturnValue(.m_enrollmentsStatus).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for enrollmentsStatus(). Use given") + Failure("Stub return value not specified for enrollmentsStatus(). Use given") + } catch { + throw error + } + return __value + } + + open func getCourseDates(courseID: String) throws -> CourseDates { + addInvocation(.m_getCourseDates__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_getCourseDates__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + var __value: CourseDates + do { + __value = try methodReturnValue(.m_getCourseDates__courseID_courseID(Parameter.value(`courseID`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for getCourseDates(courseID: String). Use given") + Failure("Stub return value not specified for getCourseDates(courseID: String). Use given") + } catch { + throw error + } + return __value + } + fileprivate enum MethodType { case m_getUserProfile__username_username(Parameter) @@ -2752,6 +2784,8 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { case m_deleteAccount__password_password(Parameter) case m_getSettings case m_saveSettings__settings(Parameter) + case m_enrollmentsStatus + case m_getCourseDates__courseID_courseID(Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { @@ -2793,6 +2827,13 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSettings, rhs: rhsSettings, with: matcher), lhsSettings, rhsSettings, "_ settings")) return Matcher.ComparisonResult(results) + + case (.m_enrollmentsStatus, .m_enrollmentsStatus): return .match + + case (.m_getCourseDates__courseID_courseID(let lhsCourseid), .m_getCourseDates__courseID_courseID(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + return Matcher.ComparisonResult(results) default: return .none } } @@ -2811,6 +2852,8 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { case let .m_deleteAccount__password_password(p0): return p0.intValue case .m_getSettings: return 0 case let .m_saveSettings__settings(p0): return p0.intValue + case .m_enrollmentsStatus: return 0 + case let .m_getCourseDates__courseID_courseID(p0): return p0.intValue } } func assertionName() -> String { @@ -2827,6 +2870,8 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { case .m_deleteAccount__password_password: return ".deleteAccount(password:)" case .m_getSettings: return ".getSettings()" case .m_saveSettings__settings: return ".saveSettings(_:)" + case .m_enrollmentsStatus: return ".enrollmentsStatus()" + case .m_getCourseDates__courseID_courseID: return ".getCourseDates(courseID:)" } } } @@ -2867,6 +2912,12 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { public static func getSettings(willReturn: UserSettings...) -> MethodStub { return Given(method: .m_getSettings, products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func enrollmentsStatus(willReturn: [CourseForSync]...) -> MethodStub { + return Given(method: .m_enrollmentsStatus, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getCourseDates(courseID: Parameter, willReturn: CourseDates...) -> MethodStub { + return Given(method: .m_getCourseDates__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func getMyProfileOffline(willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [UserProfile?] = [] let given: Given = { return Given(method: .m_getMyProfileOffline, products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -2965,6 +3016,26 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { willProduce(stubber) return given } + public static func enrollmentsStatus(willThrow: Error...) -> MethodStub { + return Given(method: .m_enrollmentsStatus, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func enrollmentsStatus(willProduce: (StubberThrows<[CourseForSync]>) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_enrollmentsStatus, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: ([CourseForSync]).self) + willProduce(stubber) + return given + } + public static func getCourseDates(courseID: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_getCourseDates__courseID_courseID(`courseID`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getCourseDates(courseID: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_getCourseDates__courseID_courseID(`courseID`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (CourseDates).self) + willProduce(stubber) + return given + } } public struct Verify { @@ -2982,6 +3053,8 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { public static func deleteAccount(password: Parameter) -> Verify { return Verify(method: .m_deleteAccount__password_password(`password`))} public static func getSettings() -> Verify { return Verify(method: .m_getSettings)} public static func saveSettings(_ settings: Parameter) -> Verify { return Verify(method: .m_saveSettings__settings(`settings`))} + public static func enrollmentsStatus() -> Verify { return Verify(method: .m_enrollmentsStatus)} + public static func getCourseDates(courseID: Parameter) -> Verify { return Verify(method: .m_getCourseDates__courseID_courseID(`courseID`))} } public struct Perform { @@ -3024,6 +3097,12 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { public static func saveSettings(_ settings: Parameter, perform: @escaping (UserSettings) -> Void) -> Perform { return Perform(method: .m_saveSettings__settings(`settings`), performs: perform) } + public static func enrollmentsStatus(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_enrollmentsStatus, performs: perform) + } + public static func getCourseDates(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getCourseDates__courseID_courseID(`courseID`), performs: perform) + } } public func given(_ method: Given) { From c291b5055a639251d38de8b6b6dc72364aec58fb Mon Sep 17 00:00:00 2001 From: Milad Emami <74170652+milad-emami@users.noreply.github.com> Date: Thu, 25 Jul 2024 11:20:48 +0330 Subject: [PATCH 30/55] fix: correct typos in localization keys, comments, and button labels (#500) Corrected spelling mistakes in various localization keys, comments, and button labels. - Changed 'Comfirm' to 'Confirm' in alert titles and confirmations - Updated localization keys: - 'LOGOUT_ALERT.TITLE' - 'DELETE_ACCOUNT.CONFIRM' - Fixed spelling in StyledButton calls and localization fallback text - Updated the Ukrainian translation for DELETE_ACCOUNT.CONFIRM - Corrected comments in the codebase These changes improve clarity and maintain consistency across the codebase. --- Core/Core/View/Base/AlertView.swift | 2 +- .../Presentation/DeleteAccount/DeleteAccountView.swift | 4 ++-- Profile/Profile/SwiftGen/Strings.swift | 6 +++--- Profile/Profile/en.lproj/Localizable.strings | 4 ++-- Profile/Profile/uk.lproj/Localizable.strings | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Core/Core/View/Base/AlertView.swift b/Core/Core/View/Base/AlertView.swift index 286fdd2e7..6b754e564 100644 --- a/Core/Core/View/Base/AlertView.swift +++ b/Core/Core/View/Base/AlertView.swift @@ -481,7 +481,7 @@ struct AlertView_Previews: PreviewProvider { .previewLayout(.sizeThatFits) .background(Color.gray) - AlertView(alertTitle: "Comfirm log out", + AlertView(alertTitle: "Confirm log out", alertMessage: "Are you sure you want to log out?", positiveAction: "Yes", onCloseTapped: {}, diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift index 044e4eb18..4e7b1e272 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift @@ -99,7 +99,7 @@ public struct DeleteAccountView: View { maxWidth: .infinity, alignment: .topLeading) - // MARK: Comfirmation button + // MARK: Confirmation button if viewModel.isShowProgress { ProgressBar(size: 40, lineWidth: 8) .padding(.top, 20) @@ -107,7 +107,7 @@ public struct DeleteAccountView: View { .accessibilityIdentifier("progress_bar") } else { StyledButton( - ProfileLocalization.DeleteAccount.comfirm, + ProfileLocalization.DeleteAccount.confirm, action: { Task { try await viewModel.deleteAccount(password: viewModel.password) diff --git a/Profile/Profile/SwiftGen/Strings.swift b/Profile/Profile/SwiftGen/Strings.swift index d83527f97..0bcf37eec 100644 --- a/Profile/Profile/SwiftGen/Strings.swift +++ b/Profile/Profile/SwiftGen/Strings.swift @@ -177,7 +177,7 @@ public enum ProfileLocalization { /// Back to profile 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") + public static let confirm = ProfileLocalization.tr("Localizable", "DELETE_ACCOUNT.CONFIRM", fallback: "Yes, delete account") /// 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. @@ -236,8 +236,8 @@ public enum ProfileLocalization { public enum LogoutAlert { /// Are you sure you want to log out? public static let text = ProfileLocalization.tr("Localizable", "LOGOUT_ALERT.TEXT", fallback: "Are you sure you want to log out?") - /// Comfirm log out - public static let title = ProfileLocalization.tr("Localizable", "LOGOUT_ALERT.TITLE", fallback: "Comfirm log out") + /// Confirm log out + public static let title = ProfileLocalization.tr("Localizable", "LOGOUT_ALERT.TITLE", fallback: "Confirm log out") } public enum Options { /// Show relative dates like “Tomorrow” and “Yesterday” diff --git a/Profile/Profile/en.lproj/Localizable.strings b/Profile/Profile/en.lproj/Localizable.strings index d94e1152c..2a680ddb2 100644 --- a/Profile/Profile/en.lproj/Localizable.strings +++ b/Profile/Profile/en.lproj/Localizable.strings @@ -28,7 +28,7 @@ "FULL_PROFILE" = "full profile"; "LIMITED_PROFILE" = "limited profile"; -"LOGOUT_ALERT.TITLE" = "Comfirm log out"; +"LOGOUT_ALERT.TITLE" = "Confirm log out"; "LOGOUT_ALERT.TEXT" = "Are you sure you want to log out?"; "DELETE_ALERT.TITLE" = "Warning!"; @@ -57,7 +57,7 @@ "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"; +"DELETE_ACCOUNT.CONFIRM" = "Yes, delete account"; "DELETE_ACCOUNT.BACK_TO_PROFILE" = "Back to profile"; "DELETE_ACCOUNT.INCORRECT_PASSWORD" = "The password is incorrect. Please try again."; diff --git a/Profile/Profile/uk.lproj/Localizable.strings b/Profile/Profile/uk.lproj/Localizable.strings index da932c17e..c56fff65c 100644 --- a/Profile/Profile/uk.lproj/Localizable.strings +++ b/Profile/Profile/uk.lproj/Localizable.strings @@ -56,7 +56,7 @@ "DELETE_ACCOUNT.DESCRIPTION" = "Для підтвердження цієї дії необхідно ввести пароль свого облікового запису."; "DELETE_ACCOUNT.PASSWORD" = "Пароль"; "DELETE_ACCOUNT.PASSWORD_DESCRIPTION" = "Введіть пароль"; -"DELETE_ACCOUNT.COMFIRM" = "Так, видалити акаунт"; +"DELETE_ACCOUNT.CONFIRM" = "Так, видалити акаунт"; "DELETE_ACCOUNT.BACK_TO_PROFILE" = "Повернутись до профілю"; "DELETE_ACCOUNT.INCORRECT_PASSWORD" = "Пароль неправильний. Будь ласка спробуйте ще раз."; From 73e6175ce1c73c6687eecac0fa9dd197776ec986 Mon Sep 17 00:00:00 2001 From: RawanMatar89 <41669180+RawanMatar89@users.noreply.github.com> Date: Thu, 25 Jul 2024 11:24:47 +0300 Subject: [PATCH 31/55] fix: RTL localization for assets and strings (#441) * fix: rtl for "arrowRight16" image in Core/Assets * fix: rtl for "chevron.right" system image in many views * fix: rtl for "CompletionStatus" Enum in CourseDates * fix: flip image to support rtl issue * fix: "CompletionStatus" in course dates localized var issue --- .../arrowRight16.imageset/Contents.json | 3 ++- Core/Core/Domain/Model/CourseDates.swift | 17 +++++++++++++++++ Core/Core/SwiftGen/Strings.swift | 12 ++++++++++++ Core/Core/View/Base/CourseButton.swift | 1 + Core/Core/en.lproj/Localizable.strings | 7 +++++++ .../Presentation/Dates/CourseDatesView.swift | 5 ++++- .../Presentation/Handouts/HandoutsView.swift | 4 +++- .../CourseStructure/CourseStructureView.swift | 0 .../CourseVertical/CourseVerticalView.swift | 1 + .../DiscussionTopics/DiscussionTopicsView.swift | 1 + .../Subviews/ProfileSupportInfoView.swift | 2 ++ .../Presentation/Settings/SettingsView.swift | 2 ++ 12 files changed, 52 insertions(+), 3 deletions(-) create mode 100644 Course/Course/Presentation/Outline/CourseStructure/CourseStructureView.swift diff --git a/Core/Core/Assets.xcassets/arrowRight16.imageset/Contents.json b/Core/Core/Assets.xcassets/arrowRight16.imageset/Contents.json index 2d22dfa63..bbe56d546 100644 --- a/Core/Core/Assets.xcassets/arrowRight16.imageset/Contents.json +++ b/Core/Core/Assets.xcassets/arrowRight16.imageset/Contents.json @@ -2,7 +2,8 @@ "images" : [ { "filename" : "arrowRight16.svg", - "idiom" : "universal" + "idiom" : "universal", + "language-direction" : "left-to-right" } ], "info" : { diff --git a/Core/Core/Domain/Model/CourseDates.swift b/Core/Core/Domain/Model/CourseDates.swift index 5b1c6436c..f3fbcdd9a 100644 --- a/Core/Core/Domain/Model/CourseDates.swift +++ b/Core/Core/Domain/Model/CourseDates.swift @@ -331,6 +331,23 @@ public enum CompletionStatus: String { case thisWeek = "This Week" case nextWeek = "Next Week" case upcoming = "Upcoming" + + public var localized: String { + switch self { + case .completed: + return CoreLocalization.CourseDates.completed + case .pastDue: + return CoreLocalization.CourseDates.pastDue + case .today: + return CoreLocalization.CourseDates.today + case .thisWeek: + return CoreLocalization.CourseDates.thisWeek + case .nextWeek: + return CoreLocalization.CourseDates.nextWeek + case .upcoming: + return CoreLocalization.CourseDates.upcoming + } + } } public extension Array { diff --git a/Core/Core/SwiftGen/Strings.swift b/Core/Core/SwiftGen/Strings.swift index 8cdf97b6e..ab42f4176 100644 --- a/Core/Core/SwiftGen/Strings.swift +++ b/Core/Core/SwiftGen/Strings.swift @@ -79,6 +79,18 @@ public enum CoreLocalization { } } public enum CourseDates { + /// Completed + public static let completed = CoreLocalization.tr("Localizable", "COURSE_DATES.COMPLETED", fallback: "Completed") + /// Next week + public static let nextWeek = CoreLocalization.tr("Localizable", "COURSE_DATES.NEXT_WEEK", fallback: "Next week") + /// Past due + public static let pastDue = CoreLocalization.tr("Localizable", "COURSE_DATES.PAST_DUE", fallback: "Past due") + /// This week + public static let thisWeek = CoreLocalization.tr("Localizable", "COURSE_DATES.THIS_WEEK", fallback: "This week") + /// Today + public static let today = CoreLocalization.tr("Localizable", "COURSE_DATES.TODAY", fallback: "Today") + /// Upcoming + public static let upcoming = CoreLocalization.tr("Localizable", "COURSE_DATES.UPCOMING", fallback: "Upcoming") public enum ResetDate { /// Your dates could not be shifted. Please try again. public static let errorMessage = CoreLocalization.tr("Localizable", "COURSE_DATES.RESET_DATE.ERROR_MESSAGE", fallback: "Your dates could not be shifted. Please try again.") diff --git a/Core/Core/View/Base/CourseButton.swift b/Core/Core/View/Base/CourseButton.swift index 9ff48554a..cfc999d5b 100644 --- a/Core/Core/View/Base/CourseButton.swift +++ b/Core/Core/View/Base/CourseButton.swift @@ -40,6 +40,7 @@ public struct CourseButton: View { .foregroundColor(Theme.Colors.textPrimary) Spacer() Image(systemName: "chevron.right") + .flipsForRightToLeftLayoutDirection(true) .padding(.vertical, 8) .foregroundColor(Theme.Colors.accentXColor) } diff --git a/Core/Core/en.lproj/Localizable.strings b/Core/Core/en.lproj/Localizable.strings index b4ca1bc64..1f8389f1f 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -139,3 +139,10 @@ "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"; + +"COURSE_DATES.TODAY" = "Today"; +"COURSE_DATES.COMPLETED" = "Completed"; +"COURSE_DATES.PAST_DUE" = "Past due"; +"COURSE_DATES.THIS_WEEK" = "This week"; +"COURSE_DATES.NEXT_WEEK" = "Next week"; +"COURSE_DATES.UPCOMING" = "Upcoming"; diff --git a/Course/Course/Presentation/Dates/CourseDatesView.swift b/Course/Course/Presentation/Dates/CourseDatesView.swift index fba76f5fe..5ec069ea5 100644 --- a/Course/Course/Presentation/Dates/CourseDatesView.swift +++ b/Course/Course/Presentation/Dates/CourseDatesView.swift @@ -241,7 +241,7 @@ struct CompletedBlocks: View { }) { HStack { VStack(alignment: .leading) { - Text(CompletionStatus.completed.rawValue) + Text(CompletionStatus.completed.localized) .font(Theme.Fonts.titleSmall) .foregroundColor(Theme.Colors.textPrimary) @@ -289,6 +289,8 @@ struct CompletedBlocks: View { if block.canShowLink && !block.firstComponentBlockID.isEmpty { Image(systemName: "chevron.right") .resizable() + .flipsForRightToLeftLayoutDirection(true) + .scaledToFit() .frame(width: 6.55, height: 11.15) .labelStyle(.iconOnly) @@ -328,6 +330,7 @@ struct BlockStatusView: View { if block.canShowLink && !block.firstComponentBlockID.isEmpty { Image(systemName: "chevron.right") .resizable() + .flipsForRightToLeftLayoutDirection(true) .scaledToFit() .frame(width: 6.55, height: 11.15) .labelStyle(.iconOnly) diff --git a/Course/Course/Presentation/Handouts/HandoutsView.swift b/Course/Course/Presentation/Handouts/HandoutsView.swift index c9a2ed6cf..f08075fb5 100644 --- a/Course/Course/Presentation/Handouts/HandoutsView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsView.swift @@ -205,7 +205,9 @@ struct HandoutsItemCell: View { .font(Theme.Fonts.labelSmall) } Spacer() - Image(systemName: "chevron.right").resizable() + Image(systemName: "chevron.right") + .resizable() + .flipsForRightToLeftLayoutDirection(true) .frame(width: 7, height: 12) .foregroundColor(Theme.Colors.accentColor) } diff --git a/Course/Course/Presentation/Outline/CourseStructure/CourseStructureView.swift b/Course/Course/Presentation/Outline/CourseStructure/CourseStructureView.swift new file mode 100644 index 000000000..e69de29bb diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift index ccfc6f9b7..cd0c8d174 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift @@ -125,6 +125,7 @@ public struct CourseVerticalView: View { } } Image(systemName: "chevron.right") + .flipsForRightToLeftLayoutDirection(true) .padding(.vertical, 8) } .padding(.horizontal, 36) diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift index ad84d6a00..5964e41ab 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift @@ -263,6 +263,7 @@ public struct TopicCell: View { .multilineTextAlignment(.leading) Spacer() Image(systemName: "chevron.right") + .flipsForRightToLeftLayoutDirection(true) .foregroundColor(Theme.Colors.accentColor) } }) diff --git a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift index 5b8f74713..29ff3c17a 100644 --- a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift +++ b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift @@ -130,6 +130,7 @@ struct ProfileSupportInfoView: View { .foregroundColor(Theme.Colors.textPrimary) Spacer() Image(systemName: "chevron.right") + .flipsForRightToLeftLayoutDirection(true) } } .simultaneousGesture(TapGesture().onEnded { @@ -187,6 +188,7 @@ struct ProfileSupportInfoView: View { .font(Theme.Fonts.titleMedium) Spacer() Image(systemName: "chevron.right") + .flipsForRightToLeftLayoutDirection(true) } } .foregroundColor(.primary) diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index 590afa784..e827d005e 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -150,6 +150,7 @@ public struct SettingsView: View { .font(Theme.Fonts.titleMedium) Spacer() Image(systemName: "chevron.right") + .flipsForRightToLeftLayoutDirection(true) } }) .accessibilityIdentifier("video_settings_button") @@ -183,6 +184,7 @@ public struct SettingsView: View { .font(Theme.Fonts.titleMedium) Spacer() Image(systemName: "chevron.right") + .flipsForRightToLeftLayoutDirection(true) } }) .accessibilityIdentifier("video_settings_button") From 157ded7df2ae608e8a76beffce247ff7d149e00e Mon Sep 17 00:00:00 2001 From: Omar Al-Ithawi Date: Mon, 19 Aug 2024 10:23:01 +0300 Subject: [PATCH 32/55] feat: remove uk translations and sync xcode project after `pull_translations` (#465) --- .github/workflows/validate-translations.yml | 49 +++ .../Authorization.xcodeproj/project.pbxproj | 2 - .../uk.lproj/Localizable.strings | 49 --- Core/Core.xcodeproj/project.pbxproj | 2 - Core/Core/uk.lproj/Localizable.strings | 127 -------- Course/Course.xcodeproj/project.pbxproj | 6 +- Course/Course/uk.lproj/Localizable.strings | 115 ------- Dashboard/Dashboard.xcodeproj/project.pbxproj | 2 - .../Dashboard/uk.lproj/Localizable.strings | 45 --- Discovery/Discovery.xcodeproj/project.pbxproj | 2 - .../Discovery/uk.lproj/Localizable.strings | 37 --- .../Discussion.xcodeproj/project.pbxproj | 2 - .../Discussion/uk.lproj/Localizable.strings | 59 ---- Makefile | 9 +- OpenEdX.xcodeproj/project.pbxproj | 8 +- OpenEdX/uk.lproj/Localizable.strings | 7 - Profile/Profile.xcodeproj/project.pbxproj | 2 - Profile/Profile/uk.lproj/Localizable.strings | 148 --------- README.md | 9 +- WhatsNew/WhatsNew.xcodeproj/project.pbxproj | 2 - .../WhatsNew/uk.lproj/Localizable.strings | 12 - i18n_scripts/requirements.txt | 5 +- i18n_scripts/translation.py | 298 ++++++++++++++++-- 23 files changed, 338 insertions(+), 659 deletions(-) create mode 100644 .github/workflows/validate-translations.yml delete mode 100644 Authorization/Authorization/uk.lproj/Localizable.strings delete mode 100644 Core/Core/uk.lproj/Localizable.strings delete mode 100644 Course/Course/uk.lproj/Localizable.strings delete mode 100644 Dashboard/Dashboard/uk.lproj/Localizable.strings delete mode 100644 Discovery/Discovery/uk.lproj/Localizable.strings delete mode 100644 Discussion/Discussion/uk.lproj/Localizable.strings delete mode 100644 OpenEdX/uk.lproj/Localizable.strings delete mode 100644 Profile/Profile/uk.lproj/Localizable.strings delete mode 100644 WhatsNew/WhatsNew/uk.lproj/Localizable.strings diff --git a/.github/workflows/validate-translations.yml b/.github/workflows/validate-translations.yml new file mode 100644 index 000000000..8bb49d670 --- /dev/null +++ b/.github/workflows/validate-translations.yml @@ -0,0 +1,49 @@ +name: Test Makefile + +on: + workflow_dispatch: + + push: + branches: [ develop ] + + pull_request: + +jobs: + translations: + name: "${{ matrix.case.name }}" + runs-on: macos-14 + + strategy: + matrix: + case: + - name: clean_translations + command: | + make clean_translations; + + - name: extract_translations + command: | + make extract_translations; + echo "Ensure combined localization file exists"; + test -f I18N/I18N/en.lproj/Localizable.strings; + + - name: pull_translations + command: + make pull_translations; + echo "Files are split properly"; + test -f Authorization/Authorization/uk.lproj/Localizable.strings; + + steps: + - uses: nschloe/action-cached-lfs-checkout@v1.2.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - name: Use Python + uses: actions/setup-python@v5 + with: + python-version: 3.11 + + - name: Install translations requirements + run: make translation_requirements + + - name: "${{ matrix.case.name }}" + run: "${{ matrix.case.command }}" diff --git a/Authorization/Authorization.xcodeproj/project.pbxproj b/Authorization/Authorization.xcodeproj/project.pbxproj index acde4b3e3..9e9990972 100644 --- a/Authorization/Authorization.xcodeproj/project.pbxproj +++ b/Authorization/Authorization.xcodeproj/project.pbxproj @@ -53,7 +53,6 @@ 025F40E129D360E20064C183 /* ResetPasswordViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetPasswordViewModel.swift; sourceTree = ""; }; 02A2ACDA2A4B016100FBBBBB /* AuthorizationAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationAnalytics.swift; sourceTree = ""; }; 02E0618329DC2373006E9024 /* ResetPasswordViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetPasswordViewModelTests.swift; sourceTree = ""; }; - 02ED50CC29A64B90008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02F3BFE4292533720051930C /* AuthorizationRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthorizationRouter.swift; sourceTree = ""; }; 071009C628D1DA4F00344290 /* SignInViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignInViewModel.swift; sourceTree = ""; }; 07169454296D913300E3DED6 /* AuthorizationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AuthorizationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -528,7 +527,6 @@ isa = PBXVariantGroup; children = ( 0770DE6C28D0C035006D8A5D /* en */, - 02ED50CC29A64B90008341CD /* uk */, ); name = Localizable.strings; sourceTree = ""; diff --git a/Authorization/Authorization/uk.lproj/Localizable.strings b/Authorization/Authorization/uk.lproj/Localizable.strings deleted file mode 100644 index 00ab874ce..000000000 --- a/Authorization/Authorization/uk.lproj/Localizable.strings +++ /dev/null @@ -1,49 +0,0 @@ -/* - Localizable.strings - Authorization - - Created by Vladimir Chekyrta on 13.09.2022. - -*/ - -"SIGN_IN.LOG_IN_TITLE" = "Увійти"; -"SIGN_IN.WELCOME_BACK" = "Welcome back! Sign in to access your courses."; -"SIGN_IN.EMAIL" = "Пошта"; -"SIGN_IN.PASSWORD" = "Пароль"; -"SIGN_IN.FORGOT_PASS_BTN" = "Забули пароль?"; -"SIGN_IN.AGREEMENT" = "By signing in to this app, 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 in -accordance with the [Privacy Policy.](%@)"; - -"ERROR.INVALID_EMAIL_ADDRESS" = "невірна адреса електронної пошти"; -"ERROR.INVALID_PASSWORD_LENGHT" = "Пароль занадто короткий або занадто довгий"; -"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.SUBTITLE" = "Create an account to start learning today!"; -"SIGN_UP.CREATE_ACCOUNT_BTN" = "Створити акаунт"; -"SIGN_UP.HIDE_FIELDS" = "Приховати необовʼязкові поля"; -"SIGN_UP.SHOW_FIELDS" = "Показати необовʼязкові поля"; -"SIGN_UP.SUCCESS_SIGNIN_LABEL" = "You've successfully signed in."; -"SIGN_UP.SUCCESS_SIGNIN_SUBLABEL" = "We just need a little more information before you start learning."; -"SIGN_UP.AGREEMENT" = "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.](%@)"; -"SIGN_UP.MARKETING_EMAIL_TITLE" = "I agree that %@ may send me marketing messages."; - -"FORGOT.TITLE"= "Відновлення паролю"; -"FORGOT.DESCRIPTION" = "Будь ласка, введіть свою адресу електронної пошти для входу або відновлення нижче, і ми надішлемо вам електронний лист з інструкціями."; -"FORGOT.REQUEST" = "Відновити пароль"; -"FORGOT.CHECK_TITLE" = "Перевірте свою електронну пошту"; -"FORGOT.CHECK_Description" = "Ми надіслали інструкції щодо відновлення пароля на вашу електронну пошту "; - -"SIGN_IN_WITH" = "Sign in with"; -"REGISTER_WITH" = "Register with"; -"APPLE" = "Apple"; -"GOOGLE" = "Google"; -"FACEBOOK" = "Facebook"; -"MICROSOFT" = "Microsoft"; -"OR" = "Or"; - -"STARTUP.INFO_MESSAGE" = "Courses and programs from the world's best universities in your pocket."; -"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 0b4400836..5e0328b90 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -289,7 +289,6 @@ 02EBC7562C19DCDB00BE182C /* SyncStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatus.swift; sourceTree = ""; }; 02EBC7582C19DE1100BE182C /* CalendarManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarManagerProtocol.swift; sourceTree = ""; }; 02EBC75A2C19DE3D00BE182C /* CourseForSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseForSync.swift; sourceTree = ""; }; - 02ED50CB29A64B84008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02F164362902A9EB0090DDEF /* StringExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StringExtension.swift; sourceTree = ""; }; 02F6EF3A28D9B8EC00835477 /* CourseCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseCellView.swift; sourceTree = ""; }; 02F6EF4928D9F0A700835477 /* DateExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateExtension.swift; sourceTree = ""; }; @@ -1293,7 +1292,6 @@ isa = PBXVariantGroup; children = ( 0770DE5C28D0B209006D8A5D /* en */, - 02ED50CB29A64B84008341CD /* uk */, ); name = Localizable.strings; sourceTree = ""; diff --git a/Core/Core/uk.lproj/Localizable.strings b/Core/Core/uk.lproj/Localizable.strings deleted file mode 100644 index f907e02f0..000000000 --- a/Core/Core/uk.lproj/Localizable.strings +++ /dev/null @@ -1,127 +0,0 @@ -/* - Localizable.strings - Core - - Created by Vladimir Chekyrta on 13.09.2022. - -*/ - -"MAINSCREEN.DISCOVERY" = "Всі курси"; -"MAINSCREEN.DASHBOARD" = "Мої курси"; -"MAINSCREEN.IN_DEVELOPING" = "В розробці"; -"MAINSCREEN.PROGRAMS" = "Програми"; -"MAINSCREEN.PROFILE" = "Профіль"; -"MAINSCREEN.LEARN" = "Навчання"; - -"VIEW.SNACKBAR.TRY_AGAIN_BTN" = "Спробувати ще"; - -"ERROR.INVALID_CREDENTIALS" = "Недійсні дані авторизації"; -"ERROR.SLOW_OR_NO_INTERNET_CONNECTION" = "Повільне або відсутнє з’єднання з Інтернетом"; -"ERROR.NO_CACHED_DATA" = "Немає збережених даних для автономного режиму"; -"ERROR.USER_NOT_ACTIVE" = "Обліковий запис користувача не активовано. Будь ласка, спочатку активуйте свій обліковий запис."; -"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" = "Модулі"; -"COURSEWARE.NEXT" = "Далі"; -"COURSEWARE.PREVIOUS" = "Назад"; -"COURSEWARE.FINISH" = "Завершити"; -"COURSEWARE.GOOD_WORK" = "Гарна робота!"; -"COURSEWARE.BACK_TO_OUTLINE" = "Повернутись до модуля"; -"COURSEWARE.SECTION_COMPLETED" = "Ви завершили “%@”."; -"COURSEWARE.CONTINUE" = "Продовжити"; -"COURSEWARE.RESUME" = "Resume"; -"COURSEWARE.RESUME_WITH" = "Продовжити далі:"; -"COURSEWARE.NEXT_SECTION" = "Наступний розділ"; - -"COURSEWARE.NEXT_SECTION_DESCRIPTION_FIRST" = "Щоб перейти до “"; -"COURSEWARE.NEXT_SECTION_DESCRIPTION_LAST" = "” натисніть “Наступний розділ”."; - -"ERROR.RELOAD" = "Перезавантажити"; - -"DATE.ENDED" = "Кінець"; -"DATE.START" = "Початок"; -"DATE.STARTED" = "Почався"; -"DATE.JUST_NOW" = "Прямо зараз"; - -"ALERT.ACCEPT" = "ТАК"; -"ALERT.CANCEL" = "СКАСУВАТИ"; -"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" = "Сховати"; -"NO_INTERNET.RELOAD" = "Перезавантажити"; - -"DATE_FORMAT.MMMM_dd" = "dd MMMM"; -"DATE_FORMAT.MMM_DD_YYYY" = "dd MMMM yyyy"; - -"DOWNLOAD_MANAGER.DOWNLOAD" = "Скачати"; -"DOWNLOAD_MANAGER.DOWNLOADED" = "Скачано"; -"DOWNLOAD_MANAGER.COMPLETED" = "Завершено"; - -"SETTINGS.VIDEO_DOWNLOAD_QUALITY_TITLE" = "Video download quality"; -"SETTINGS.DOWNLOAD_QUALITY_AUTO_TITLE" = "Auto"; -"SETTINGS.DOWNLOAD_QUALITY_AUTO_DESCRIPTION" = "Recommended"; -"SETTINGS.DOWNLOAD_QUALITY_360_TITLE" = "360p"; -"SETTINGS.DOWNLOAD_QUALITY_360_DESCRIPTION" = "Lower data usage"; -"SETTINGS.DOWNLOAD_QUALITY_540_TITLE" = "540p"; -"SETTINGS.DOWNLOAD_QUALITY_720_TITLE" = "720p"; -"SETTINGS.DOWNLOAD_QUALITY_720_DESCRIPTION" = "Best quality"; - -"DONE" = "Зберегти"; - -"PICKER.SEARCH" = "Знайти"; -"PICKER.ACCEPT" = "Прийняти"; - -"WEBVIEW.ALERT.OK" = "Так"; -"WEBVIEW.ALERT.CANCEL" = "Скасувати"; -"WEBVIEW.ALERT.CONTINUE" = "Continue"; - - -"REVIEW.VOTE_TITLE" = "Вам подобається Open edX?"; -"REVIEW.VOTE_DESCRIPTION" = "Ваш відгук важливий для нас. Можливо, ви візьмете хвилинку, щоб оцінити додаток, натиснувши на зірку нижче? Дякуємо за вашу підтримку!"; -"REVIEW.FEEDBACK_TITLE" = "Залиште відгук"; -"REVIEW.FEEDBACK_DESCRIPTION" = "Нам шкода чути, що ваше навчання мало деякі проблеми. Ми вдячні за будь-який відгук."; -"REVIEW.THANKS_FOR_VOTE_TITLE" = "Дякуємо"; -"REVIEW.THANKS_FOR_VOTE_DESCRIPTION" = "Дякуємо, що поділилися своїми враженнями з нами. Бажаєте залишити свій відгук про цей додаток для інших користувачів в магазині додатків?"; -"REVIEW.THANKS_FOR_FEEDBACK_TITLE" = "Дякуємо"; -"REVIEW.THANKS_FOR_FEEDBACK_DESCRIPTION" = "Ми отримали ваш відгук і використовуватимемо його для покращення вашого навчального досвіду в майбутньому!"; -"REVIEW.BETTER" = "Що можна було б зробити краще?"; -"REVIEW.NOT_NOW" = "Не зараз"; - -"REVIEW.BUTTON.SUBMIT" = "Надіслати"; -"REVIEW.BUTTON.SHARE_FEEDBACK" = "Поділитися відгуком"; -"REVIEW.BUTTON.RATE_US" = "Оцінити нас"; -"REVIEW.EMAIL.TITLE" = "Виберіть поштового клієнта:"; - -"SOCIAL_SIGN_CANCELED" = "The user canceled the sign-in flow."; -"AUTHORIZATION_FAILED" = "Authorization failed."; - -"SIGN_IN.LOG_IN_BTN" = "Увійти"; -"REGISTER" = "Реєстрація"; - -"TOMORROW" = "Завтра"; -"YESTERDAY" = "Учора"; -"OPEN_IN_BROWSER"="Переглянути в Safari"; -"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BODY" = "Не хвилюйтеся - перенесіть наш запропонований розклад, щоб виконати прострочені завдання без втрати прогресу."; -"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.BUTTON" = "Зміщення термінів виконання"; -"COURSE_DATES.RESET_DATE.RESET_DATE_BANNER.HEADER" = "Пропустив деякі терміни?"; -"COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.BODY" = "Ми склали запропонований розклад, щоб допомогти вам не відставати від курсу. Але не хвилюйтеся – він гнучкий, тож ви можете навчатися у своєму власному темпі. Якщо трапиться, що ви відстаєте, ви будете мати можливість коригувати дати, щоб тримати себе в курсі."; -"COURSE_DATES.RESET_DATE.TAB_INFO_BANNER.HEADER" = ""; -"COURSE_DATES.RESET_DATE.UPGRADE_TO_COMPLETE_GRADED_BANNER.BODY" = "Щоб виконати оцінені завдання в рамках цього курсу, ви можете оновити сьогодні."; "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" = "Ви перевіряєте цей курс, що означає, що ви не можете брати участь у оцінюваних завданнях. Схоже, що ви пропустили деякі важливі терміни згідно з нашим запропонованим розкладом. Щоб виконати оцінені завдання в рамках цей курс і перенести прострочені завдання в майбутнє, ви можете оновити сьогодні."; -"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.BUTTON" = ""; -"COURSE_DATES.RESET_DATE.UPGRADE_TO_RESET_BANNER.HEADER" = ""; -"COURSE_DATES.RESET_DATE.ERROR_MESSAGE" = "Ваші дати не можуть бути зміщені. Спробуйте ще раз."; -"COURSE_DATES.RESET_DATE.SUCCESS_MESSAGE" = "Ваші дати успішно перенесено."; "COURSE_DATES.RESET_DATE.TITLE" = "Дати курсу"; diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index 33c43eeaf..2ec932caa 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -154,7 +154,6 @@ 02D4FC2D2BBD7C9C00C47748 /* MessageSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSectionView.swift; sourceTree = ""; }; 02E3803D2BFF9F0A00815AFA /* CourseProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseProgressView.swift; sourceTree = ""; }; 02EBC7542C19CFCF00BE182C /* CalendarSyncStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarSyncStatusView.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 = ""; }; 02F3BFDC29252E900051930C /* CourseRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseRouter.swift; sourceTree = ""; }; @@ -309,8 +308,8 @@ 02B6B3B828E1D12900232911 /* Data */, 02B6B3B528E1D10700232911 /* Domain */, 02EAE2CA28E1F0A700529644 /* Presentation */, - 97CA95212B875EA200A9EDEA /* Views */, - 97EA4D822B84EFA900663F58 /* Managers */, + 97CA95212B875EA200A9EDEA, + 97EA4D822B84EFA900663F58, 02B6B3B428E1C49400232911 /* Localizable.strings */, 02C355372C08DCD700501342 /* Localizable.stringsdict */, ); @@ -937,7 +936,6 @@ isa = PBXVariantGroup; children = ( 02B6B3B328E1C49400232911 /* en */, - 02ED50CF29A64BB6008341CD /* uk */, ); name = Localizable.strings; sourceTree = ""; diff --git a/Course/Course/uk.lproj/Localizable.strings b/Course/Course/uk.lproj/Localizable.strings deleted file mode 100644 index 76d23b095..000000000 --- a/Course/Course/uk.lproj/Localizable.strings +++ /dev/null @@ -1,115 +0,0 @@ -/* - Localizable.strings - Course - - Created by  Stepanok Ivan on 26.09.2022. - -*/ - -"OUTLINE.PASSED_THE_COURSE" = "Вітаємо, ви отримали сертифікат курсу “%@\.“"; -"OUTLINE.VIEW_CERTIFICATE" = "Переглянути сертифікат"; -"OUTLINE.CERTIFICATE" = "Сертифікат"; -"OUTLINE.COURSE_VIDEOS" = "Відео з курсу"; - -"COURSEWARE.COURSE_CONTENT" = "Зміст курсу"; -"COURSEWARE.COURSE_UNITS" = "Модулі"; -"COURSEWARE.NEXT" = "Далі"; -"COURSEWARE.PREVIOUS" = "Назад"; -"COURSEWARE.FINISH" = "Завершити"; -"COURSEWARE.GOOD_WORK" = "Гарна робота!"; -"COURSEWARE.BACK_TO_OUTLINE" = "Повернутись до модуля"; -"COURSEWARE.SECTION" = "Секція “"; -"COURSEWARE.IS_FINISHED" = "“ завершена."; -"COURSEWARE.CONTINUE" = "Продовжити"; -"COURSEWARE.RESUME_WITH" = "Продовжити далі:"; - -"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"; -"ALERT.DELETE_ALL_VIDEOS" = "Are you sure you want to delete all video(s) for"; -"ALERT.DELETE_VIDEOS" = "Are you sure you want to delete video(s) for"; -"ALERT.STOP_DOWNLOADING" = "Turning off the switch will stop downloading and delete all downloaded videos for"; -"ALERT.WARNING" = "Warning"; - -"COURSE_CONTAINER.COURSE" = "Курс"; -"COURSE_CONTAINER.VIDEOS" = "Всі відео"; -"COURSE_CONTAINER.DATES" = "Dates"; -"COURSE_CONTAINER.DISCUSSIONS" = "Дискусії"; -"COURSE_CONTAINER.HANDOUTS" = "Матеріали"; -"COURSE_CONTAINER.HANDOUTS_IN_DEVELOPING" = "Матеріали в процесі розробки"; - -"HANDOUTS_CELL_HANDOUTS.TITLE" = "Нотатки"; -"HANDOUTS_CELL_ANNOUNCEMENTS.TITLE" = "Оголошення"; -"HANDOUTS_CELL_HANDOUTS.DESCRIPTION" = "Знайдіть важливу інформацію про курс"; -"HANDOUTS_CELL_ANNOUNCEMENTS.DESCRIPTION" = "Будьте в курсі останніх новин"; - -"NOT_AVALIABLE.TITLE" = "Цей інтерактивний компонент не доступний"; -"NOT_AVALIABLE.DESCRIPTION" = "Досліджуйте інші частини цього курсу або перегляньте цю в Браузері."; -"NOT_AVALIABLE.BUTTON" = "Відкрити в браузері"; - -"SUBTITLES.TITLE" = "Субтитри"; - -"ACCESSIBILITY.DOWNLOAD" = "Скачати"; -"ACCESSIBILITY.CANCEL_DOWNLOAD" = "Скасувати завантаження"; -"ACCESSIBILITY.DELETE_DOWNLOAD" = "Видалити файл"; - -"DOWNLOAD.DOWNLOADS" = "Downloads"; -"DOWNLOAD.DOWNLOAD" = "Download"; -"DOWNLOAD.ALL_VIDEOS_DOWNLOADED" = "All videos downloaded"; -"DOWNLOAD.DOWNLOADING_VIDEOS" = "Downloading videos..."; -"DOWNLOAD.DOWNLOAD_TO_DEVICE" = "Download to device"; -"DOWNLOAD.VIDEOS" = "Videos"; -"DOWNLOAD.REMAINING" = "Remaining"; -"DOWNLOAD.UNTITLED"= "Untitled"; -"DOWNLOAD.TOTAL"= "Total"; - -"DOWNLOAD.CHANGE_QUALITY_ALERT" = "You cannot change the download video quality when all videos are downloading"; -"DOWNLOAD.DOWNLOAD_LARGE_FILE_MESSAGE" = "The videos you've selected are larger than 1 GB. Do you want to download these videos?"; -"DOWNLOAD.NO_WIFI_MESSAGE" = "Your current download settings only allow downloads over Wi-Fi.\nPlease connect to a Wi-Fi network or change your download settings."; - -"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.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.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"; - -"COURSE.DUE_TODAY" = "Закінчується сьогодні"; -"COURSE.DUE_TOMORROW" = "Закінчується завтра"; - -"COURSE.PROGRESS_COMPLETED" = "%@ з %@ завдань виконано"; -"CALENDAR_SYNC_STATUS.SYNCED" = "Синхронізовано з календарем"; -"CALENDAR_SYNC_STATUS.FAILED" = "Помилка синхронізації календаря"; -"CALENDAR_SYNC_STATUS.OFFLINE" = "Офлайн"; diff --git a/Dashboard/Dashboard.xcodeproj/project.pbxproj b/Dashboard/Dashboard.xcodeproj/project.pbxproj index c6e7e4e52..817ab758a 100644 --- a/Dashboard/Dashboard.xcodeproj/project.pbxproj +++ b/Dashboard/Dashboard.xcodeproj/project.pbxproj @@ -70,7 +70,6 @@ 02A9A9082978194100B55797 /* DashboardTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DashboardTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 02A9A90A2978194100B55797 /* DashboardViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardViewModelTests.swift; sourceTree = ""; }; 02A9A92829781A4D00B55797 /* DashboardMock.generated.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DashboardMock.generated.swift; sourceTree = ""; }; - 02ED50CD29A64B9B008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02EF39E728D89F560058F6BD /* Dashboard.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Dashboard.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 02F175322A4DABBF0019CD70 /* DashboardAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardAnalytics.swift; sourceTree = ""; }; 02F3BFE029252FCB0051930C /* DashboardRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardRouter.swift; sourceTree = ""; }; @@ -532,7 +531,6 @@ isa = PBXVariantGroup; children = ( 02F6EF4428D9ECC500835477 /* en */, - 02ED50CD29A64B9B008341CD /* uk */, ); name = Localizable.strings; sourceTree = ""; diff --git a/Dashboard/Dashboard/uk.lproj/Localizable.strings b/Dashboard/Dashboard/uk.lproj/Localizable.strings deleted file mode 100644 index e02337c90..000000000 --- a/Dashboard/Dashboard/uk.lproj/Localizable.strings +++ /dev/null @@ -1,45 +0,0 @@ -/* - Localizable.strings - Dashboard - - Created by  Stepanok Ivan on 20.09.2022. - -*/ - -"TITLE" = "Мої курси"; -"HEADER.COURSES" = "Курси"; -"HEADER.WELCOME_BACK" = "З поверненням. Давайте продовжимо вчитись."; - -"SEARCH" = "Пошук"; - -"EMPTY.TITLE" = "Нічого немає"; -"EMPTY.SUBTITLE" = "Ви не підписані на жодний курс."; - -"LEARN.TITLE" = "Навчання"; -"LEARN.VIEW_ALL" = "Переглянути все (%@)"; -"LEARN.ALL_COURSES" = "Усі курси"; - -"LEARN.PRIMARY_CARD.ONE_PAST_ASSIGNMENT" = "1 прострочене завдання"; -"LEARN.PRIMARY_CARD.VIEW_ASSIGNMENTS" = "Переглянути завдання"; -"LEARN.PRIMARY_CARD.PAST_ASSIGNMENTS" = "%@ Прострочені завдання"; -"LEARN.PRIMARY_CARD.FUTURE_ASSIGNMENTS" = "%@ Завданнь %@"; -"LEARN.PRIMARY_CARD.DUE_DAYS" = "%@ Оплата через %@ днів"; -"LEARN.PRIMARY_CARD.RESUME" = "Відновити курс"; -"LEARN.PRIMARY_CARD.START_COURSE" = "Розпочати курс"; - -"LEARN.DROPDOWN_MENU.COURSES" = "Курси"; -"LEARN.DROPDOWN_MENU.PROGRAMS" = "Програми"; - -"LEARN.CATEGORY.ALL" = "Усі"; -"LEARN.CATEGORY.IN_PROGRESS" = "Виконується"; -"LEARN.CATEGORY.COMPLETED" = "Завершено"; -"LEARN.CATEGORY.EXPIRED" = "Закінчився"; - -"LEARN.NO_COURSES_VIEW.NO_COURSES" = "Немає курсів"; -"LEARN.NO_COURSES_VIEW.NO_COURSES_IN_PROGRESS" = "Немає поточних курсів"; -"LEARN.NO_COURSES_VIEW.NO_COMPLETED_COURSES" = "Немає завершених курсів"; -"LEARN.NO_COURSES_VIEW.NO_EXPIRED_COURSES" = "Немає прострочених курсів"; - -"LEARN.NO_COURSES_VIEW.NO_COURSES_DESCRIPTION" = "Наразі ви не зареєстровані на жодному курсі, бажаєте переглянути каталог?"; - -"LEARN.NO_COURSES_VIEW.FIND_A_COURSE" = "Знайти курс"; diff --git a/Discovery/Discovery.xcodeproj/project.pbxproj b/Discovery/Discovery.xcodeproj/project.pbxproj index 769376097..c8958d0c0 100644 --- a/Discovery/Discovery.xcodeproj/project.pbxproj +++ b/Discovery/Discovery.xcodeproj/project.pbxproj @@ -67,7 +67,6 @@ 029242EA2AE6AB7B00A940EC /* UpdateNotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UpdateNotificationView.swift; sourceTree = ""; }; 0297373F2949FB070051696B /* DiscoveryCoreModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = DiscoveryCoreModel.xcdatamodel; sourceTree = ""; }; 029737412949FB3B0051696B /* DiscoveryPersistenceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryPersistenceProtocol.swift; sourceTree = ""; }; - 02ED50C729A649C9008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02ED50C829A649C9008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = uk; path = uk.lproj/Localizable.stringsdict; sourceTree = ""; }; 02EF39D028D867690058F6BD /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = ""; }; 02EF39D828D86A380058F6BD /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; @@ -591,7 +590,6 @@ isa = PBXVariantGroup; children = ( 02EF39D828D86A380058F6BD /* en */, - 02ED50C729A649C9008341CD /* uk */, ); name = Localizable.strings; sourceTree = ""; diff --git a/Discovery/Discovery/uk.lproj/Localizable.strings b/Discovery/Discovery/uk.lproj/Localizable.strings deleted file mode 100644 index 25f73bf53..000000000 --- a/Discovery/Discovery/uk.lproj/Localizable.strings +++ /dev/null @@ -1,37 +0,0 @@ -/* - Localizable.strings - Discovery - - Created by  Stepanok Ivan on 19.09.2022. - -*/ - -"TITLE" = "Всі курси"; -"SEARCH" = "Пошук"; -"HEADER.TITLE_1" = "Всі курси"; -"HEADER.TITLE_2" = "Давайте знайдемо нові курси для вас"; - -"SEARCH.TITLE" = "Результати пошуку"; -"SEARCH.EMPTY_DESCRIPTION" = "Почніть вводити текст, щоб знайти курс"; - -"UPDATE_REQUIRED_TITLE" = "Потрібне оновлення додатка"; -"UPDATE_REQUIRED_DESCRIPTION" = "Ця версія додатка OpenEdX застаріла. Щоб продовжити навчання та отримати останні функції та виправлення, оновіться до останньої версії."; -"UPDATE_WHY_NEED" = "Чому я маю оновити програму?"; -"UPDATE_DEPRECATED_APP" = "Застаріла версія додатка"; -"UPDATE_BUTTON" = "Оновити"; -"UPDATE_ACCOUNT_SETTINGS" = "Налаштування"; - -"UPDATE_NEEDED_TITLE" = "Оновлення додатку"; -"UPDATE_NEEDED_DESCRIPTION" = "Ми рекомендуємо вам оновити додаток до останньої версії. Оновіть зараз, щоб отримати нові функції та виправлення."; -"UPDATE_NEEDED_NOT_NOW" = "Не зараз"; - -"UPDATE_NEW_AVALIABLE" = "Доступне нове оновлення! Оновіть зараз, щоб отримати найновіші функції та виправлення"; - -"ALERT.LEAVING_APP_TITLE" = "Leaving the app"; -"ALERT.LEAVING_APP_MESSAGE" = "You are now leaving the app and opening a browser"; - -"DETAILS.TITLE" = "Деталі курсу"; -"DETAILS.VIEW_COURSE" = "Переглянути курс"; -"DETAILS.ENROLL_NOW" = "Зареєструватися"; -"DETAILS.ENROLLMENT_DATE_IS_OVER" = "Ви не можете зареєструватися на цей курс, оскільки дата реєстрації закінчилася."; -"DETAILS.ENROLLMENT_NO_INTERNET" = "Щоб зареєструватися на цьому курсі, переконайтеся, що ви підключені до Інтернету."; diff --git a/Discussion/Discussion.xcodeproj/project.pbxproj b/Discussion/Discussion.xcodeproj/project.pbxproj index 2f63c3d88..657642ca2 100644 --- a/Discussion/Discussion.xcodeproj/project.pbxproj +++ b/Discussion/Discussion.xcodeproj/project.pbxproj @@ -107,7 +107,6 @@ 02D1267528F76F5D00C8E689 /* DiscussionTopicsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscussionTopicsView.swift; sourceTree = ""; }; 02D1267728F76FF200C8E689 /* DiscussionTopicsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscussionTopicsViewModel.swift; sourceTree = ""; }; 02E4F18029A8C2FD00F31684 /* DiscussionSearchTopicsViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscussionSearchTopicsViewModelTests.swift; sourceTree = ""; }; - 02ED50D029A64BBF008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02ED50D129A64BBF008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = uk; path = uk.lproj/Localizable.stringsdict; sourceTree = ""; }; 02F175382A4DD5AA0019CD70 /* DiscussionAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscussionAnalytics.swift; sourceTree = ""; }; 02F28A5D28FF23E700AFDE1B /* ThreadView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadView.swift; sourceTree = ""; }; @@ -733,7 +732,6 @@ isa = PBXVariantGroup; children = ( 0218196128F734CD00202564 /* en */, - 02ED50D029A64BBF008341CD /* uk */, ); name = Localizable.strings; sourceTree = ""; diff --git a/Discussion/Discussion/uk.lproj/Localizable.strings b/Discussion/Discussion/uk.lproj/Localizable.strings deleted file mode 100644 index fc5223c14..000000000 --- a/Discussion/Discussion/uk.lproj/Localizable.strings +++ /dev/null @@ -1,59 +0,0 @@ -/* - Localizable.strings - Discussion - - Created by  Stepanok Ivan on 12.10.2022. - -*/ - -"TITLE" = "Дискусії"; -"BANNER.DISCUSSIONS_IS_DISABLED" = "Posting in discussions is disabled by the course team"; - -"TOPICS.SEARCH" = "Пошук по всім постам"; -"TOPICS.ALL_POSTS" = "Всі пости"; -"TOPICS.POST_IM_FOLLOWING" = "Улюблені пости"; -"TOPICS.MAIN_CATEGORIES" = "Основні категорії"; -"TOPICS.UNNAMED" = "Unnamed subcategory"; - -"POSTS.SORT.RECENT_ACTIVITY" = "Остання активність"; -"POSTS.SORT.MOST_ACTIVITY" = "Найактивниші"; -"POSTS.SORT.MOST_VOTES" = "Найбільше голосів"; - -"POSTS.FILTER.ALL_POSTS" = "Всі пости"; -"POSTS.FILTER.UNREAD" = "Непрочитаних"; -"POSTS.FILTER.UNANSWERED" = "Без відповіді"; -"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" = "Оберіть"; - -"POST.LAST_POST" = "Останній пост:"; - -"THREAD.ALERT.COMMENT_ADDED" = "Коментарій додано"; -"THREAD.ADD_RESPONSE" = "Додати відповідь"; - -"CREATE_THREAD.NEW_POST" = "Створити новий пост"; -"CREATE_THREAD.SELECT_POST_TYPE" = "Обрати тип посту"; -"CREATE_THREAD.TOPIC" = "Тема"; -"CREATE_THREAD.TITLE" = "Назва"; -"CREATE_THREAD.FOLLOW_DISCUSSION" = "Підписатись на дискусію"; -"CREATE_THREAD.FOLLOW_QUESTION" = "Підписатись на питання"; -"CREATE_THREAD.CREATE_DISCUSSION" = "Створити нову дискусію"; -"CREATE_THREAD.CREATE_QUESTION" = "Створити нове питання"; - -"COMMENT.REPORT" = "Поскаржитись"; -"COMMENT.UNREPORT" = "Зняти скаргу"; -"COMMENT.FOLLOW" = "Слідкувати"; -"COMMENT.UNFOLLOW" = "Не слідкувати"; - -"RESPONSE.COMMENTS_RESPONSES" = "Коментарій"; -"RESPONSE.ALERT.COMMENT_ADDED" = "Коментарій додано"; -"RESPONSE.ADD_COMMENT" = "Додати коментарій"; - -"POST_TYPE.QUESTION" = "питання"; -"POST_TYPE.DISCUSSION" = "дискусія"; - -"ANONYMOUS" = "Анонім"; diff --git a/Makefile b/Makefile index 5f97f7c59..0d88ddcfc 100644 --- a/Makefile +++ b/Makefile @@ -1,12 +1,13 @@ -clean_translations_temp_directory: +clean_translations: rm -rf I18N/ + python3 i18n_scripts/translation.py --clean translation_requirements: pip3 install -r i18n_scripts/requirements.txt -pull_translations: clean_translations_temp_directory +pull_translations: clean_translations atlas pull $(ATLAS_OPTIONS) translations/openedx-app-ios/I18N:I18N - python3 i18n_scripts/translation.py --split --replace-underscore + python3 i18n_scripts/translation.py --split --replace-underscore --add-xcode-files -extract_translations: clean_translations_temp_directory +extract_translations: clean_translations python3 i18n_scripts/translation.py --combine diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 02d6d8680..653283d3c 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -114,7 +114,6 @@ 0293A2082A6FCDE50090A336 /* DashboardPersistence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardPersistence.swift; sourceTree = ""; }; 0298DF2F2A4EF7230023A257 /* AnalyticsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AnalyticsManager.swift; sourceTree = ""; }; 02B6B3C428E1E61400232911 /* CourseDetails.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = CourseDetails.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - 02ED50CA29A64AAA008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02ED50D529A6554E008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = uk; path = "uk.lproj/сountries.json"; sourceTree = ""; }; 02ED50D729A65554008341CD /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = Base; path = "Base.lproj/сountries.json"; sourceTree = ""; }; 02ED50D929A66007008341CD /* Base */ = {isa = PBXFileReference; lastKnownFileType = text.json; name = Base; path = Base.lproj/languages.json; sourceTree = ""; }; @@ -675,7 +674,6 @@ isa = PBXVariantGroup; children = ( 0770DE6528D0BCC7006D8A5D /* en */, - 02ED50CA29A64AAA008341CD /* uk */, ); name = Localizable.strings; sourceTree = ""; @@ -1281,8 +1279,8 @@ requirement = { kind = upToNextMajorVersion; minimumVersion = 10.26.0; - }; - }; + }; + }; 14D912D12C25483F0077CCCE /* XCRemoteSwiftPackageReference "fullstory-swift-package-ios" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/fullstorydev/fullstory-swift-package-ios"; @@ -1335,7 +1333,7 @@ isa = XCSwiftPackageProductDependency; package = 0780ABE12BFBA2E40093A4A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; productName = FirebaseMessaging; - }; + }; 14D912D22C25483F0077CCCE /* FullStory */ = { isa = XCSwiftPackageProductDependency; package = 14D912D12C25483F0077CCCE /* XCRemoteSwiftPackageReference "fullstory-swift-package-ios" */; diff --git a/OpenEdX/uk.lproj/Localizable.strings b/OpenEdX/uk.lproj/Localizable.strings deleted file mode 100644 index 8e7d62729..000000000 --- a/OpenEdX/uk.lproj/Localizable.strings +++ /dev/null @@ -1,7 +0,0 @@ -/* - Localizable.strings - OpenEdX - - Created by Vladimir Chekyrta on 13.09.2022. - -*/ diff --git a/Profile/Profile.xcodeproj/project.pbxproj b/Profile/Profile.xcodeproj/project.pbxproj index afc5ff356..0005825de 100644 --- a/Profile/Profile.xcodeproj/project.pbxproj +++ b/Profile/Profile.xcodeproj/project.pbxproj @@ -113,7 +113,6 @@ 02D0FD082AD698380020D752 /* UserProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileView.swift; sourceTree = ""; }; 02D0FD0A2AD6984D0020D752 /* UserProfileViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfileViewModel.swift; sourceTree = ""; }; 02EBC7522C19CD1700BE182C /* CalendarManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarManager.swift; sourceTree = ""; }; - 02ED50CE29A64BAD008341CD /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02F175342A4DAD030019CD70 /* ProfileAnalytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileAnalytics.swift; sourceTree = ""; }; 02F3BFE6292539850051930C /* ProfileRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileRouter.swift; sourceTree = ""; }; 02F81DDE2BF4D83E002D3604 /* CalendarDialogView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarDialogView.swift; sourceTree = ""; }; @@ -726,7 +725,6 @@ isa = PBXVariantGroup; children = ( 021D926028DDADE600ACC565 /* en */, - 02ED50CE29A64BAD008341CD /* uk */, ); name = Localizable.strings; sourceTree = ""; diff --git a/Profile/Profile/uk.lproj/Localizable.strings b/Profile/Profile/uk.lproj/Localizable.strings deleted file mode 100644 index c56fff65c..000000000 --- a/Profile/Profile/uk.lproj/Localizable.strings +++ /dev/null @@ -1,148 +0,0 @@ -/* - Localizable.strings - Profile - - Created by  Stepanok Ivan on 23.09.2022. - -*/ - -"TITLE" = "Профіль"; -"INFO" = "Дані профілю"; -"ABOUT" = "Про Meне"; -"EDIT_PROFILE" = "Редагування"; -"YEAR_OF_BIRTH" = "Рік народження:"; -"BIO" = "Біо:"; -"SETTINGS" = "Налаштування"; -"SETTINGS_VIDEO" = "Налаштування відео"; -"SUPPORT_INFO" = "Інформація про підтримку"; -"CONTACT" = "Cлужби підтримки"; -"TERMS" = "Умови використання"; -"PRIVACY" = "Політика конфіденційності"; -"COOKIE_POLICY" = "Cookie policy"; -"DO_NOT_SELL_INFORMATION" = "Do not sell my personal information"; -"FAQ_TITLE" = "View FAQ"; -"LOGOUT" = "Вийти"; -"SWITCH_TO" = "Переключити на"; -"FULL_PROFILE" = "повний профіль"; -"LIMITED_PROFILE" = "обмежений профіль"; -"MANAGE_ACCOUNT" = "Налаштування Профілю"; - -"LOGOUT_ALERT.TITLE" = "Підтвердження виходу"; -"LOGOUT_ALERT.TEXT" = "Ви впевнені, що бажаєте вийти?"; - -"DELETE_ALERT.TITLE" = "Увага!"; -"DELETE_ALERT.TEXT" = "Ви дійсно хочете видалити свій обліковий запис?"; - -"UNSAVED_DATA_ALERT.TITLE" = "Є незбережені дані"; -"UNSAVED_DATA_ALERT.TEXT" = "Ви дійсно хочете вийти без збереження?"; - -"EDIT.TOO_YONG_USER" = "Вам має бути більше 13 років, щоб мати профіль із повним доступом до інформації."; -"EDIT.LIMITED_PROFILE_DESCRIPTION" = "В обмеженому профілі доступні лише ваше ім’я користувача."; -"EDIT.DELETE_ACCOUNT" = "Видалити акаунт"; - -"EDIT.FIELDS.YEAR_OF_BIRTH" = "Рік народження:"; -"EDIT.FIELDS.LOCATION" = "Країна"; -"EDIT.FIELDS.SPOKEN_LANGUGAE" = "Мова спілкування"; -"EDIT.FIELDS.ABOUT_ME" = "Про мене:"; - -"EDIT.BOTTOM_SHEET.TITLE" = "Змінити фото профілю"; -"EDIT.BOTTOM_SHEET.SELECT" = "Обрати із галереї"; -"EDIT.BOTTOM_SHEET.REMOVE" = "Видалити зображення"; -"EDIT.BOTTOM_SHEET.CANCEL" = "Скасувати"; - -"DELETE_ACCOUNT.TITLE" = "Видалення акаунту"; -"DELETE_ACCOUNT.ARE_YOU_SURE" = "Ви впевнені, що хочете "; -"DELETE_ACCOUNT.WANT_TO_DELETE" = "видалити свій обліковий запис?"; -"DELETE_ACCOUNT.DESCRIPTION" = "Для підтвердження цієї дії необхідно ввести пароль свого облікового запису."; -"DELETE_ACCOUNT.PASSWORD" = "Пароль"; -"DELETE_ACCOUNT.PASSWORD_DESCRIPTION" = "Введіть пароль"; -"DELETE_ACCOUNT.CONFIRM" = "Так, видалити акаунт"; -"DELETE_ACCOUNT.BACK_TO_PROFILE" = "Повернутись до профілю"; -"DELETE_ACCOUNT.INCORRECT_PASSWORD" = "Пароль неправильний. Будь ласка спробуйте ще раз."; - -"SETTINGS.VIDEO_SETTINGS_TITLE" = "Налаштування відео"; -"SETTINGS.WIFI_TITLE" = "Тільки Wi-fi"; -"SETTINGS.WIFI_DESCRIPTION" = "Завантажувати відео, лише коли Wi-Fi увімкнено"; -"SETTINGS.VIDEO_QUALITY_TITLE" = "Якість потокового відео"; -"SETTINGS.VIDEO_QUALITY_DESCRIPTION" = "Авто (Рекомендовано)"; - -"SETTINGS.QUALITY_AUTO_TITLE" = "Авто"; -"SETTINGS.QUALITY_AUTO_DESCRIPTION" = "Рекомендовано"; -"SETTINGS.QUALITY_360_TITLE" = "360p"; -"SETTINGS.QUALITY_360_DESCRIPTION" = "економія трафіку"; -"SETTINGS.QUALITY_540_TITLE" = "540p"; -"SETTINGS.QUALITY_720_TITLE" = "720p"; -"SETTINGS.QUALITY_AUTO_DESCRIPTION" = "Найкраща якість"; - -"SETTINGS.VERSION" = "Версія:"; -"SETTINGS.UP_TO_DATE" = "Оновлено"; -"SETTINGS.TAP_TO_UPDATE" = "Клацніть, щоб оновити до версії"; -"SETTINGS.TAP_TO_INSTALL" = "Клацніть, щоб встановити обов'язкове оновлення програми"; - -"ERROR.CANNOT_SEND_EMAIL" = "Cannot send email. It seems your email client is not set up."; - -"CALENDAR.NEW_CALENDAR" = "Новий календар"; -"CALENDAR.CHANGE_SYNC_OPTIONS" = "Змінити параметри синхронізації"; -"CALENDAR.ACCOUNT" = "Обліковий запис"; -"CALENDAR.CALENDAR_NAME" = "Назва календаря"; -"CALENDAR.COLOR" = "Колір"; -"CALENDAR.UPCOMING_ASSIGNMENTS" = "Майбутні завдання для активних курсів з’являться у цьому календарі"; -"CALENDAR.CANCEL" = "Скасувати"; -"CALENDAR.BEGIN_SYNCING" = "Почати синхронізацію"; - -"ASSIGNMENT_STATUS.SYNCED" = "Синхронізовано"; -"ASSIGNMENT_STATUS.FAILED" = "Синхронізація не вдалася"; -"ASSIGNMENT_STATUS.OFFLINE" = "Офлайн"; - -"CALENDAR_DIALOG.CALENDAR_ACCESS" = "Доступ до календаря"; -"CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC" = "Cкасувати синхронізацію календаря"; -"CALENDAR_DIALOG.CALENDAR_ACCESS_DESCRIPTION" = "Щоб показати майбутні завдання та віхи курсу у вашому календарі, нам потрібен дозвіл на доступ до вашого календаря."; -"CALENDAR_DIALOG.DISABLE_CALENDAR_SYNC_DESCRIPTION" = "Вимкнення синхронізації календаря видалить календар “%@”. Ви можете знову увімкнути синхронізацію календаря в будь-який час."; -"CALENDAR_DIALOG.GRANT_CALENDAR_ACCESS" = "Надати доступ до календаря"; -"CALENDAR_DIALOG.DISABLE_SYNCING" = "Вимкнути синхронізацію"; -"CALENDAR_DIALOG.CANCEL" = "Скасувати"; - -"DATES_AND_CALENDAR.TITLE" = "Дати та календар"; -"CALENDAR_SYNC.TITLE" = "Синхронізація календаря"; -"CALENDAR_SYNC.DESCRIPTION" = "Налаштуйте синхронізацію календаря, щоб показувати майбутні завдання та віхи курсу у вашому календарі. Нові завдання та змінені дати курсів будуть синхронізуватися автоматично"; -"CALENDAR_SYNC.BUTTON" = "Налаштувати синхронізацію календаря"; -"OPTIONS.TITLE" = "Опції"; -"OPTIONS.USE_RELATIVE_DATES" = "Використовувати відносні дати"; -"OPTIONS.SHOW_RELATIVE_DATES" = "Показувати відносні дати, такі як “Завтра” і “Вчора”"; - -"DATES_AND_CALENDAR.TITLE" = "Дати та календарі"; -"CALENDAR_SYNC.TITLE" = "Синхронізація календаря"; -"COURSE_CALENDAR_SYNC.TITLE" = "Синхронізація календаря курсу"; -"COURSE_CALENDAR_SYNC.DESCRIPTION.RECONNECT_REQUIRED" = "Будь ласка, повторно підключіть свій календар для відновлення синхронізації"; -"COURSE_CALENDAR_SYNC.DESCRIPTION.SYNCING" = "В даний час події синхронізуються з вашим календарем"; -"COURSE_CALENDAR_SYNC.BUTTON.RECONNECT" = "Повторно підключити календар"; -"COURSE_CALENDAR_SYNC.BUTTON.CHANGE_SYNC_OPTIONS" = "Змінити параметри синхронізації"; -"COURSES_TO_SYNC.TITLE" = "Синхронізація %d курсів"; -"OPTIONS.TITLE" = "Опції"; -"OPTIONS.USE_RELATIVE_DATES" = "Використовувати відносні дати"; -"OPTIONS.SHOW_RELATIVE_DATES" = "Показувати відносні дати, такі як “Завтра” і “Вчора”"; - -"COURSES_TO_SYNC.TITLE" = "Курси для синхронізації"; -"COURSES_TO_SYNC.DESCRIPTION" = "Вимкнення синхронізації для курсу видалить усі події, пов’язані з курсом, із вашого синхронізованого календаря."; -"COURSES_TO_SYNC.HIDE_INACTIVE_COURSES" = "Приховати неактивні курси"; -"COURSES_TO_SYNC.HIDE_INACTIVE_COURSES_DESCRIPTION" = "Автоматично видаляйте події з курсів, які ви не переглядали протягом останнього місяця"; -"COURSES_TO_SYNC.INACTIVE" = "Неактивний"; - -"CALENDAR.DROPDOWN.ICLOUD" = "iCloud"; -"CALENDAR.DROPDOWN.LOCAL" = "Локальний"; - -"CALENDAR.DROPDOWN_COLOR.ACCENT" = "Акцентний"; -"CALENDAR.DROPDOWN_COLOR.RED" = "Червоний"; -"CALENDAR.DROPDOWN_COLOR.ORANGE" = "Помаранчевий"; -"CALENDAR.DROPDOWN_COLOR.YELLOW" = "Жовтий"; -"CALENDAR.DROPDOWN_COLOR.GREEN" = "Зелений"; -"CALENDAR.DROPDOWN_COLOR.BLUE" = "Синій"; -"CALENDAR.DROPDOWN_COLOR.PURPLE" = "Фіолетовий"; -"CALENDAR.DROPDOWN_COLOR.BROWN" = "Коричневий"; - -"CALENDAR.COURSE_DATES" = "%@ Дати курсу"; - -"DROP_DOWN_PICKER.SELECT" = "Оберіть"; - -"SYNC.NO_SYNCED" = "Немає синхронізованих курсів"; -"SYNC.NO_SYNCED_DESCRIPTION" = "Жоден курс зараз не синхронізується з вашим календарем."; diff --git a/README.md b/README.md index bb4b9b578..01addfd08 100644 --- a/README.md +++ b/README.md @@ -32,10 +32,17 @@ Then, to get the latest translations for all languages use the following command ```bash make pull_translations ``` + This command runs [`atlas pull`](https://github.com/openedx/openedx-atlas) to download the latest translations files from the [openedx/openedx-translations](https://github.com/openedx/openedx-translations) repository. These files contain the latest translations for all languages. In the [openedx/openedx-translations](https://github.com/openedx/openedx-translations) repository each language's translations are saved as a single file e.g. `I18N/I18N/uk.lproj/Localization.strings` ([example](https://github.com/openedx/openedx-translations/blob/6448167e9695a921f003ff6bd8f40f006a2d6743/translations/openedx-app-ios/I18N/I18N/uk.lproj/Localizable.strings)). After these are pulled, each language's translation file is split into the App's modules e.g. `Discovery/Discovery/uk.lproj/Localization.strings`. After this command is run the application can load the translations by changing the device (or the emulator) language in the settings. +**Note:** This command modifies the XCode project files which fails the build so it's required to clean the translations files before committing using the following command: + +``` +make clean_translations +``` + ### Using custom translations By default, the command `make pull_translations` runs [`atlas pull`](https://github.com/openedx/openedx-atlas) with no arguments which pulls transaltions from the [openedx-translations repository](https://github.com/openedx/openedx-translations). @@ -55,7 +62,7 @@ Additional arguments can be passed to `atlas pull`. Refer to the [atlas document Translations are managed in the [open-edx/openedx-translations](https://app.transifex.com/open-edx/openedx-translations/dashboard/) Transifex project. -To translate the app join the [Transifex project](https://app.transifex.com/open-edx/openedx-translations/dashboard/) and add your translations `openedx-app-ios` resource: https://app.transifex.com/open-edx/openedx-translations/openedx-app-ios/ (the link will start working after the [pull request #442](https://github.com/openedx/openedx-app-ios/pull/422) is merged) +To translate the app join the [Transifex project](https://app.transifex.com/open-edx/openedx-translations/dashboard/) and add your translations to the [`openedx-app-ios`](https://app.transifex.com/open-edx/openedx-translations/openedx-app-ios/) resource. Once the resource is both 100% translated and reviewed the [Transifex integration](https://github.com/apps/transifex-integration) will automatically push it to the [openedx-translations](https://github.com/openedx/openedx-translations) repository and developers can use the translations in their app. diff --git a/WhatsNew/WhatsNew.xcodeproj/project.pbxproj b/WhatsNew/WhatsNew.xcodeproj/project.pbxproj index b78d64a7d..62b52f41d 100644 --- a/WhatsNew/WhatsNew.xcodeproj/project.pbxproj +++ b/WhatsNew/WhatsNew.xcodeproj/project.pbxproj @@ -52,7 +52,6 @@ 02B54E102AE061C100C56962 /* WhatsNewRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewRouter.swift; sourceTree = ""; }; 02E640782ADFF5920079AEDA /* WhatsNewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewView.swift; sourceTree = ""; }; 02E6407D2ADFF6250079AEDA /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; - 02E6407F2ADFF6270079AEDA /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = uk; path = uk.lproj/Localizable.strings; sourceTree = ""; }; 02E640802ADFFE440079AEDA /* WhatsNewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewViewModel.swift; sourceTree = ""; }; 02E640852ADFFF380079AEDA /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 02E640892AE004300079AEDA /* Strings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; @@ -473,7 +472,6 @@ isa = PBXVariantGroup; children = ( 02E6407D2ADFF6250079AEDA /* en */, - 02E6407F2ADFF6270079AEDA /* uk */, ); name = Localizable.strings; sourceTree = ""; diff --git a/WhatsNew/WhatsNew/uk.lproj/Localizable.strings b/WhatsNew/WhatsNew/uk.lproj/Localizable.strings deleted file mode 100644 index a0194425c..000000000 --- a/WhatsNew/WhatsNew/uk.lproj/Localizable.strings +++ /dev/null @@ -1,12 +0,0 @@ -/* - Localizable.strings - WhatsNew - - Created by  Stepanok Ivan on 18.10.2023. - -*/ - -"TITLE" = "Що нового"; -"BUTTON_PREVIOUS" = "Назад"; -"BUTTON_NEXT" = "Далі"; -"BUTTON_DONE" = "Завершити"; diff --git a/i18n_scripts/requirements.txt b/i18n_scripts/requirements.txt index 384c433ad..197d6c5be 100644 --- a/i18n_scripts/requirements.txt +++ b/i18n_scripts/requirements.txt @@ -1,3 +1,6 @@ # Translation processing dependencies openedx-atlas==0.6.1 -localizable==0.1.3 \ No newline at end of file +localizable==0.1.3 + +# Using `pbxproj==4.2.0` with the ( https://github.com/kronenthaler/mod-pbxproj/pull/356 ) patch to support Localizable.strings files +https://github.com/kronenthaler/mod-pbxproj/archive/a9187b42dc224827f162c3e7b9b34f4c0d2654ee.zip diff --git a/i18n_scripts/translation.py b/i18n_scripts/translation.py index 5a56ca48e..8fd979591 100644 --- a/i18n_scripts/translation.py +++ b/i18n_scripts/translation.py @@ -13,7 +13,16 @@ import re import sys from collections import defaultdict +from contextlib import contextmanager +from pathlib import Path + import localizable +from pbxproj import XcodeProject +from pbxproj.pbxextensions import FileOptions + +LOCALIZABLE_FILES_TREE = '' +MAIN_MODULE_NAME = 'OpenEdX' +I18N_MODULE_NAME = 'I18N' def parse_arguments(): @@ -28,51 +37,94 @@ def parse_arguments(): help='Split translations into separate files for each module and language.') group.add_argument('--combine', action='store_true', help='Combine the English translations from all modules into a single file.') + group.add_argument('--clean', action='store_true', + help='Remove translation files and clean XCode projects.') parser.add_argument('--replace-underscore', action='store_true', - help='Replace underscores with "-r" in language directories (only with --split).') + help='Replace Transifex underscore "ar_IQ" language code with ' + 'iOS-compatible "ar-rIQ" codes (only with --split).') + parser.add_argument('--add-xcode-files', action='store_true', + help='Add the language files to the XCode project (only with --split).') return parser.parse_args() -def get_translation_file_path(modules_dir, module_name, lang_dir, create_dirs=False): +@contextmanager +def change_directory(new_dir: Path): + """ + Context manager to execute `os.chidir`. + + Usage: + + with change_directory('/some/path'): + do_stuff_here() + + :param new_dir: Path + """ + original_dir = os.getcwd() + try: + os.chdir(new_dir) + yield + finally: + os.chdir(original_dir) + + +def get_modules_dir(override: Path = None) -> Path: + """ + Gets the modeles directory (repository root directory). + """ + if override: + return override + + return Path(__file__).absolute().parent.parent + + +def get_translation_file_path(modules_dir: Path, module_name, lang_dir, create_dirs=False): """ Retrieves the path of the translation file for a specified module and language directory. Parameters: - modules_dir (str): The path to the base directory containing all the modules. + modules_dir (Path): The path to the base directory containing all the modules. module_name (str): The name of the module for which the translation path is being retrieved. lang_dir (str): The name of the language directory within the module's directory. create_dirs (bool): If True, creates the parent directories if they do not exist. Defaults to False. Returns: - str: The path to the module's translation file (Localizable.strings). + Path: The path to the module's translation file (Localizable.strings). """ try: - lang_dir_path = os.path.join(modules_dir, module_name, module_name, lang_dir, 'Localizable.strings') + if module_name == MAIN_MODULE_NAME: + # The main project structure is located into `OpenEdX` rather than `OpenEdX/OpenEdX` + module_path = modules_dir / module_name + else: + # Rest of modules such as Core, Course, Dashboard, etc follow the `Dashboard/Dashboard` structure + module_path = modules_dir / module_name / module_name + + lang_dir_path = module_path / lang_dir if create_dirs: - os.makedirs(os.path.dirname(lang_dir_path), exist_ok=True) - return lang_dir_path + lang_dir_path.mkdir(parents=True, exist_ok=True) + return lang_dir_path / 'Localizable.strings' except Exception as e: print(f"Error creating directory path: {e}", file=sys.stderr) raise -def get_modules_to_translate(modules_dir): +def get_modules_to_translate(modules_dir: Path): """ Retrieve the names of modules that have translation files for a specified language. Parameters: - modules_dir (str): The path to the directory containing all the modules. + modules_dir (Path): The path to the directory containing all the modules. Returns: list of str: A list of module names that have translation files for the specified language. """ try: modules_list = [ - directory for directory in os.listdir(modules_dir) + module_dir for module_dir in os.listdir(modules_dir) if ( - os.path.isdir(os.path.join(modules_dir, directory)) - and os.path.isfile(get_translation_file_path(modules_dir, directory, 'en.lproj')) - and directory != 'I18N' + (modules_dir / module_dir).is_dir() + and os.path.isfile(get_translation_file_path(modules_dir, module_dir, 'en.lproj')) + and module_dir != I18N_MODULE_NAME + and module_dir != MAIN_MODULE_NAME ) ] return modules_list @@ -84,12 +136,12 @@ def get_modules_to_translate(modules_dir): raise -def get_translations(modules_dir): +def get_translations(modules_dir: Path): """ Retrieve the translations from all modules in the modules_dir. Parameters: - modules_dir (str): The directory containing the modules. + modules_dir (Path): The directory containing the modules. Returns: dict: A dict containing a list of dictionaries containing the 'key', 'value', and 'comment' for each @@ -114,7 +166,7 @@ def get_translations(modules_dir): print(f"Error retrieving translations: {e}", file=sys.stderr) raise - return {'I18N': translations} + return {I18N_MODULE_NAME: translations} def combine_translation_files(modules_dir=None): @@ -122,8 +174,7 @@ def combine_translation_files(modules_dir=None): Combine translation files from different modules into a single file. """ try: - if not modules_dir: - modules_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + modules_dir = get_modules_dir(override=modules_dir) translation = get_translations(modules_dir) write_translations_to_modules(modules_dir, 'en.lproj', translation) except Exception as e: @@ -131,19 +182,19 @@ def combine_translation_files(modules_dir=None): raise -def get_languages_dirs(modules_dir): +def get_languages_dirs(modules_dir: Path): """ Retrieve directories containing language files for translation. Args: - modules_dir (str): The directory containing all the modules. + modules_dir (Path): The directory containing all the modules. Returns: list: A list of directories containing language files for translation. Each directory represents a specific language and ends with the '.lproj' extension. """ try: - lang_parent_dir = os.path.join(modules_dir, 'I18N', 'I18N') + lang_parent_dir = modules_dir / I18N_MODULE_NAME / I18N_MODULE_NAME languages_dirs = [ directory for directory in os.listdir(lang_parent_dir) if directory.endswith('.lproj') and directory != "en.lproj" @@ -173,7 +224,7 @@ def get_translations_from_file(modules_dir, lang_dir): """ translations = defaultdict(list) try: - translations_file_path = get_translation_file_path(modules_dir, 'I18N', lang_dir) + translations_file_path = get_translation_file_path(modules_dir, I18N_MODULE_NAME, lang_dir) lang_list = localizable.parse_strings(filename=translations_file_path) for translation_entry in lang_list: module_name, key_remainder = translation_entry['key'].split('.', maxsplit=1) @@ -189,7 +240,7 @@ def get_translations_from_file(modules_dir, lang_dir): return translations -def write_translations_to_modules(modules_dir, lang_dir, modules_translations): +def write_translations_to_modules(modules_dir: Path, lang_dir, modules_translations): """ Write translations to language files for each module. @@ -211,6 +262,12 @@ def write_translations_to_modules(modules_dir, lang_dir, modules_translations): print(f"Error writing translations to file.\n Module: {module}\n Error: {e}", file=sys.stderr) raise + # The main project structure is located into `OpenEdX` rather than `OpenEdX/OpenEdX` + # Empty files are added, so iOS knows which languages are supported in this app + main_translation_file_path = get_translation_file_path(modules_dir, MAIN_MODULE_NAME, lang_dir, create_dirs=True) + with open(main_translation_file_path, 'w') as f: + f.write(f'/* Empty {lang_dir}/Localizable.strings: Created by i18n_scripts/translation.py */') + def _escape(s): """ @@ -249,8 +306,7 @@ def split_translation_files(modules_dir=None): None """ try: - if not modules_dir: - modules_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + modules_dir = get_modules_dir(override=modules_dir) languages_dirs = get_languages_dirs(modules_dir) for lang_dir in languages_dirs: translations = get_translations_from_file(modules_dir, lang_dir) @@ -260,21 +316,197 @@ def split_translation_files(modules_dir=None): raise -def replace_underscores(modules_dir=None): +def get_project_path(modules_dir: Path, module_name: str) -> Path: + """ + Using a module_name return the pbxproj path. + + :param modules_dir: + :param module_name: + :return: Path + """ + if module_name == MAIN_MODULE_NAME: + project_file_path = modules_dir / f'{module_name}.xcodeproj/project.pbxproj' + else: + project_file_path = modules_dir / module_name / f'{module_name}.xcodeproj/project.pbxproj' + + return project_file_path + + +def get_xcode_project(modules_dir: Path, module_name: str) -> XcodeProject: + """ + Initialize an XCode project instance for a given module. + """ + xcode_project = XcodeProject.load(get_project_path(modules_dir, module_name)) + return xcode_project + + +def list_translation_files(module_path: Path) -> [Path]: + """ + List translaiton files in a given path. + + This method doesn't return the `en.lproj` translation source strings. + """ + for localizable_abs_path in module_path.rglob('**/Localizable.strings'): + if localizable_abs_path.parent.name != 'en.lproj': + yield localizable_abs_path + + +def get_xcode_projects(modules_dir: Path) -> [{Path, XcodeProject}]: + """ + Return a list of module_name, xcode_project pairs. + """ + for module_name in get_modules_to_translate(modules_dir): + xcode_project = get_xcode_project(modules_dir, module_name) + yield module_name, xcode_project + + +def add_localizable(xcode_project: XcodeProject, localizable_relative_path: Path): + """ + Add localizable file properly to the PBXVariantGroup. + + This function depends on the https://github.com/kronenthaler/mod-pbxproj/pull/356 implementation. + + TODO: Refactor to use the `master` version once either of the following issues is closed: + - Issue by st3fan: https://github.com/kronenthaler/mod-pbxproj/issues/113 + - Proposal by OmarIthawi for Axim: https://github.com/kronenthaler/mod-pbxproj/pull/356 + + :param xcode_project: XcodeProject + :param localizable_relative_path: Path + :return: + """ + language, _rest = str(localizable_relative_path).split('.lproj') # e.g. `ar` or `fr-ca` + print(f' - Adding "{localizable_relative_path}" for the "{language}" language.') + localizable_groups = xcode_project.get_groups_by_name(name='Localizable.strings', + section='PBXVariantGroup') + if len(localizable_groups) != 1: + # We need a single group. If many are found then, it's a problem. + raise Exception(f'Error: Cannot find the Localizable.strings group, please add the English ' + f'source translation strings with the name Localizable.strings. ' + f'Results: "{localizable_groups}"') + localizable_group = localizable_groups[0] + + xcode_project.add_file( + str(localizable_relative_path), + name=language, + parent=localizable_group, + force=False, + tree=LOCALIZABLE_FILES_TREE, + file_options=FileOptions( + create_build_files=False, + ), + ) + + +def add_translation_files_to_xcode(modules_dir: Path = None): + """ + Add Localizable.strings files pulled from Transifex to XCode. + """ + try: + modules_dir = get_modules_dir(override=modules_dir) + for module_name, xcode_project in get_xcode_projects(modules_dir): + print(f'## Entering project: {module_name}') + module_path = modules_dir / module_name + project_files_path = module_path / module_name # e.g. openedx-app-ios/Authorization/Authorization + + with change_directory(project_files_path): + for localizable_abs_path in list_translation_files(module_path): + add_localizable( + xcode_project=xcode_project, + localizable_relative_path=localizable_abs_path.relative_to(project_files_path), + ) + xcode_project.save() + + # This project is used to specify which languages are supported by the app for iOS + print(f'## Entering project: {MAIN_MODULE_NAME}') + main_xcode_project = get_xcode_project(modules_dir, module_name=MAIN_MODULE_NAME) + main_xcode_project_module_path = modules_dir / MAIN_MODULE_NAME + with change_directory(main_xcode_project_module_path): + # The main project structure is located into `OpenEdX` rather than `OpenEdX/OpenEdX` + for localizable_abs_path in list_translation_files(main_xcode_project_module_path): + add_localizable( + xcode_project=main_xcode_project, + localizable_relative_path=localizable_abs_path.relative_to(main_xcode_project_module_path), + ) + main_xcode_project.save() + + except Exception as e: + print(f"Error: An unexpected error occurred in add_translation_files_to_xcode: {e}", file=sys.stderr) + raise + + +def remove_xcode_localizable_variants(xcode_project: XcodeProject) -> None: + """ + Remove all non-English localizable files from the XCode project. + + :param xcode_project: XcodeProject + :return: + """ + for file_ref in xcode_project.objects.get_objects_in_section('PBXFileReference'): + if ( + not file_ref.path.startswith('en.lproj') + and re.match(r'\w+.lproj', file_ref.path) + and file_ref.sourceTree == LOCALIZABLE_FILES_TREE + and getattr(file_ref, 'lastKnownFileType', None) == 'text.plist.strings' + ): + path = file_ref.path + language, _rest = str(path).split('.lproj') # e.g. `ar` or `fr-ca` + print(f' - Removing "{path}" from project resources for the "{language}" language.') + xcode_project.remove_files_by_path(file_ref.path, tree=LOCALIZABLE_FILES_TREE, target_name=language) + + +def delete_translation_files(module_path: Path, xcode_project_path_base: Path): + """ + Delete the files from the file system. + + :param module_path: Path + :param xcode_project_path_base: Path + :return: + """ + for localizable_abs_path in list_translation_files(module_path): + localizable_relative_path = localizable_abs_path.relative_to(xcode_project_path_base) + print(f' - Removing "{localizable_relative_path}" file from file system') + localizable_abs_path.unlink() + + +def clean_translation_files(modules_dir: Path = None): + """ + Remove translation files from both file system and XCode project files. + """ try: - if not modules_dir: - modules_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + modules_dir = get_modules_dir(override=modules_dir) + for module_name, xcode_project in get_xcode_projects(modules_dir): + print(f'## Entering project: {module_name}') + module_path = modules_dir / module_name + delete_translation_files(module_path, xcode_project_path_base=module_path/module_name) + remove_xcode_localizable_variants(xcode_project) + xcode_project.save() + + # This project is used to specify which languages are supported by the app for iOS + print(f'## Entering project: {MAIN_MODULE_NAME}') + main_xcode_project = get_xcode_project(modules_dir, module_name=MAIN_MODULE_NAME) + main_xcode_project_module_path = modules_dir / MAIN_MODULE_NAME + # The main project structure is located into `OpenEdX` rather than `OpenEdX/OpenEdX` + delete_translation_files(main_xcode_project_module_path, main_xcode_project_module_path) + remove_xcode_localizable_variants(main_xcode_project) + main_xcode_project.save() + except Exception as e: + print(f"Error: An unexpected error occurred in clean_translation_files: {e}", file=sys.stderr) + raise + +def replace_underscores(modules_dir=None): + try: + modules_dir = get_modules_dir(override=modules_dir) languages_dirs = get_languages_dirs(modules_dir) for lang_dir in languages_dirs: + lang_old_path = os.path.dirname(get_translation_file_path(modules_dir, I18N_MODULE_NAME, lang_dir)) try: pattern = r'_(\w\w.lproj$)' if re.search(pattern, lang_dir): replacement = r'-\1' new_name = re.sub(pattern, replacement, lang_dir) - lang_old_path = os.path.dirname(get_translation_file_path(modules_dir, 'I18N', lang_dir)) - lang_new_path = os.path.dirname(get_translation_file_path(modules_dir, 'I18N', new_name)) + lang_new_path = os.path.dirname(get_translation_file_path(modules_dir, I18N_MODULE_NAME, new_name)) os.rename(lang_old_path, lang_new_path) print(f"Renamed {lang_old_path} to {lang_new_path}") @@ -286,7 +518,7 @@ def replace_underscores(modules_dir=None): print(f"Error: Permission denied while renaming {lang_old_path}: {e}", file=sys.stderr) raise except Exception as e: - print(f"Error: An unexpected error occurred while renaming {lang_old_path} to {lang_new_path}: {e}", + print(f"Error: An unexpected error occurred while renaming {lang_old_path}: {e}", file=sys.stderr) raise @@ -301,8 +533,12 @@ def main(): if args.replace_underscore: replace_underscores() split_translation_files() + if args.add_xcode_files: + add_translation_files_to_xcode() elif args.combine: combine_translation_files() + elif args.clean: + clean_translation_files() if __name__ == "__main__": From a405dce658e8431eed1f3d1c6e65535489eb495f Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Fri, 23 Aug 2024 11:11:17 +0300 Subject: [PATCH 33/55] feat: [FC-0047] Relative Dates (#505) * feat: initial commit * feat: relative dates * fix: address feedback * fix: update tests * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback --- Core/Core/Data/CoreStorage.swift | 6 +- Core/Core/Data/Model/Data_CourseDates.swift | 5 +- .../Core/Data/Repository/AuthRepository.swift | 7 +- Core/Core/Domain/Model/CourseDates.swift | 3 +- Core/Core/Extensions/DateExtension.swift | 167 +++++++++++------- Core/Core/SwiftGen/Strings.swift | 18 ++ Core/Core/View/Base/CourseCellView.swift | 10 +- Core/Core/en.lproj/Localizable.strings | 6 + Course/Course/Data/CourseRepository.swift | 4 +- .../Unit/CourseDateViewModelTests.swift | 54 ++++-- .../Presentation/AllCoursesView.swift | 7 +- .../Presentation/AllCoursesViewModel.swift | 5 +- .../Elements/CourseCardView.swift | 12 +- .../Elements/PrimaryCardView.swift | 30 +++- .../Presentation/ListDashboardView.swift | 8 +- .../Presentation/ListDashboardViewModel.swift | 5 +- .../PrimaryCourseDashboardView.swift | 8 +- .../PrimaryCourseDashboardViewModel.swift | 5 +- .../DashboardViewModelTests.swift | 28 ++- .../NativeDiscovery/DiscoveryView.swift | 4 +- .../NativeDiscovery/DiscoveryViewModel.swift | 2 +- .../NativeDiscovery/SearchView.swift | 15 +- .../NativeDiscovery/SearchViewModel.swift | 3 + .../Presentation/SearchViewModelTests.swift | 6 +- .../Discussion/Domain/Model/UserThread.swift | 31 ++-- .../Base/BaseResponsesViewModel.swift | 5 +- .../Comments/Base/CommentCell.swift | 19 +- .../Comments/Base/ParentCommentView.swift | 8 +- .../Comments/Responses/ResponsesView.swift | 10 +- .../Responses/ResponsesViewModel.swift | 3 +- .../Comments/Thread/ThreadView.swift | 14 +- .../Comments/Thread/ThreadViewModel.swift | 4 +- .../DiscussionSearchTopicsView.swift | 3 +- .../DiscussionSearchTopicsViewModel.swift | 5 +- .../Presentation/Posts/PostsView.swift | 3 +- .../Presentation/Posts/PostsViewModel.swift | 32 ++-- .../Base/BaseResponsesViewModelTests.swift | 81 +++------ .../Comment/ThreadViewModelTests.swift | 8 + ...DiscussionSearchTopicsViewModelTests.swift | 6 +- .../Posts/PostViewModelTests.swift | 39 ++-- .../Responses/ResponsesViewModelTests.swift | 7 + OpenEdX/DI/ScreenAssembly.swift | 16 +- OpenEdX/Data/AppStorage.swift | 31 +++- Profile/Profile.xcodeproj/project.pbxproj | 4 + Profile/Profile/Data/ProfileRepository.swift | 2 +- Profile/Profile/Data/ProfileStorage.swift | 2 + .../DatesAndCalendarView.swift | 27 +-- .../DatesAndCalendarViewModel.swift | 6 +- .../Elements/RelativeDatesToggleView.swift | 42 +++++ .../Models/CalendarSettings.swift | 8 +- .../SyncCalendarOptionsView.swift | 17 +- Profile/Profile/SwiftGen/Strings.swift | 2 + Profile/Profile/en.lproj/Localizable.strings | 1 + .../ProfileTests/ProfileMock.generated.swift | 20 +-- 54 files changed, 548 insertions(+), 326 deletions(-) create mode 100644 Profile/Profile/Presentation/DatesAndCalendar/Elements/RelativeDatesToggleView.swift diff --git a/Core/Core/Data/CoreStorage.swift b/Core/Core/Data/CoreStorage.swift index 60837da41..ef09cd079 100644 --- a/Core/Core/Data/CoreStorage.swift +++ b/Core/Core/Data/CoreStorage.swift @@ -13,12 +13,13 @@ public protocol CoreStorage { var pushToken: String? {get set} var appleSignFullName: String? {get set} var appleSignEmail: String? {get set} - var cookiesDate: String? {get set} + var cookiesDate: Date? {get set} var reviewLastShownVersion: String? {get set} var lastReviewDate: Date? {get set} var user: DataLayer.User? {get set} var userSettings: UserSettings? {get set} var resetAppSupportDirectoryUserData: Bool? {get set} + var useRelativeDates: Bool {get set} func clear() } @@ -29,12 +30,13 @@ public class CoreStorageMock: CoreStorage { public var pushToken: String? public var appleSignFullName: String? public var appleSignEmail: String? - public var cookiesDate: String? + public var cookiesDate: Date? public var reviewLastShownVersion: String? public var lastReviewDate: Date? public var user: DataLayer.User? public var userSettings: UserSettings? public var resetAppSupportDirectoryUserData: Bool? + public var useRelativeDates: Bool = true public func clear() {} public init() {} diff --git a/Core/Core/Data/Model/Data_CourseDates.swift b/Core/Core/Data/Model/Data_CourseDates.swift index 35616b020..e17f767ce 100644 --- a/Core/Core/Data/Model/Data_CourseDates.swift +++ b/Core/Core/Data/Model/Data_CourseDates.swift @@ -166,7 +166,7 @@ public extension DataLayer { } public extension DataLayer.CourseDates { - var domain: CourseDates { + func domain(useRelativeDates: Bool) -> CourseDates { return CourseDates( datesBannerInfo: DatesBannerInfo( missedDeadlines: datesBannerInfo?.missedDeadlines ?? false, @@ -186,7 +186,8 @@ public extension DataLayer.CourseDates { linkText: block.linkText ?? nil, title: block.title, extraInfo: block.extraInfo, - firstComponentBlockID: block.firstComponentBlockID) + firstComponentBlockID: block.firstComponentBlockID, + useRelativeDates: useRelativeDates) }, hasEnded: hasEnded, learnerIsFullAccess: learnerIsFullAccess, diff --git a/Core/Core/Data/Repository/AuthRepository.swift b/Core/Core/Data/Repository/AuthRepository.swift index e4adf93ea..d00441ee6 100644 --- a/Core/Core/Data/Repository/AuthRepository.swift +++ b/Core/Core/Data/Repository/AuthRepository.swift @@ -88,15 +88,14 @@ public class AuthRepository: AuthRepositoryProtocol { public func getCookies(force: Bool) async throws { if let cookiesCreatedDate = appStorage.cookiesDate, !force { - let cookiesCreated = Date(iso8601: cookiesCreatedDate) - let cookieLifetimeLimit = cookiesCreated.addingTimeInterval(60 * 60) + let cookieLifetimeLimit = cookiesCreatedDate.addingTimeInterval(60 * 60) if Date() > cookieLifetimeLimit { _ = try await api.requestData(AuthEndpoint.getAuthCookies) - appStorage.cookiesDate = Date().dateToString(style: .iso8601) + appStorage.cookiesDate = Date() } } else { _ = try await api.requestData(AuthEndpoint.getAuthCookies) - appStorage.cookiesDate = Date().dateToString(style: .iso8601) + appStorage.cookiesDate = Date() } } diff --git a/Core/Core/Domain/Model/CourseDates.swift b/Core/Core/Domain/Model/CourseDates.swift index f3fbcdd9a..11c0e943d 100644 --- a/Core/Core/Domain/Model/CourseDates.swift +++ b/Core/Core/Domain/Model/CourseDates.swift @@ -156,9 +156,10 @@ public struct CourseDateBlock: Identifiable { public let title: String public let extraInfo: String? public let firstComponentBlockID: String + public let useRelativeDates: Bool public var formattedDate: String { - return date.dateToString(style: .shortWeekdayMonthDayYear) + return date.dateToString(style: .shortWeekdayMonthDayYear, useRelativeDates: useRelativeDates) } public var isInPast: Bool { diff --git a/Core/Core/Extensions/DateExtension.swift b/Core/Core/Extensions/DateExtension.swift index 7be0c84ec..cb07e81f6 100644 --- a/Core/Core/Extensions/DateExtension.swift +++ b/Core/Core/Extensions/DateExtension.swift @@ -14,7 +14,7 @@ public extension Date { var date: Date var dateFormatter: DateFormatter? dateFormatter = DateFormatter() - dateFormatter?.locale = Locale(identifier: "en_US_POSIX") + dateFormatter?.locale = .current date = formats.compactMap { format in dateFormatter?.dateFormat = format @@ -33,16 +33,75 @@ public extension Date { self.init(timeInterval: 0, since: date) } - func timeAgoDisplay() -> String { - let formatter = RelativeDateTimeFormatter() - formatter.locale = .current - formatter.unitsStyle = .full - formatter.locale = Locale(identifier: "en_US_POSIX") - if description == Date().description { - return CoreLocalization.Date.justNow - } else { - return formatter.localizedString(for: self, relativeTo: Date()) + func timeAgoDisplay(dueIn: Bool = false) -> String { + let currentDate = Date() + let calendar = Calendar.current + + let dueString = dueIn ? CoreLocalization.Date.due : "" + let dueInString = dueIn ? CoreLocalization.Date.dueIn : "" + + let startOfCurrentDate = calendar.startOfDay(for: currentDate) + let startOfSelfDate = calendar.startOfDay(for: self) + + let daysRemaining = Calendar.current.dateComponents( + [.day], + from: startOfCurrentDate, + to: self + ).day ?? 0 + + // Calculate date ranges + guard let sevenDaysAgo = calendar.date(byAdding: .day, value: -7, to: startOfCurrentDate), + let sevenDaysAhead = calendar.date(byAdding: .day, value: 7, to: startOfCurrentDate) else { + return dueInString + self.dateToString(style: .mmddyy, useRelativeDates: false) + } + + let isCurrentYear = calendar.component(.year, from: self) == calendar.component(.year, from: startOfCurrentDate) + + if calendar.isDateInToday(startOfSelfDate) { + return dueString + CoreLocalization.Date.today + } + + if calendar.isDateInYesterday(startOfSelfDate) { + return dueString + CoreLocalization.yesterday + } + + if calendar.isDateInTomorrow(startOfSelfDate) { + return dueString + CoreLocalization.tomorrow } + + if startOfSelfDate > startOfCurrentDate && startOfSelfDate <= sevenDaysAhead { + let weekdayFormatter = DateFormatter() + weekdayFormatter.dateFormat = "EEEE" + if startOfSelfDate == calendar.date(byAdding: .day, value: 1, to: startOfCurrentDate) { + return dueInString + CoreLocalization.tomorrow + } else if startOfSelfDate == calendar.date(byAdding: .day, value: 7, to: startOfCurrentDate) { + return CoreLocalization.Date.next(weekdayFormatter.string(from: startOfSelfDate)) + } else { + return dueIn ? ( + CoreLocalization.Date.dueInDays(daysRemaining) + ) : weekdayFormatter.string(from: startOfSelfDate) + } + } + + if startOfSelfDate < startOfCurrentDate && startOfSelfDate >= sevenDaysAgo { + guard let daysAgo = calendar.dateComponents([.day], from: startOfSelfDate, to: startOfCurrentDate).day else { + return self.dateToString(style: .mmddyy, useRelativeDates: false) + } + return CoreLocalization.Date.daysAgo(daysAgo) + } + + let specificFormatter = DateFormatter() + specificFormatter.dateFormat = isCurrentYear ? "MMMM d" : "MMMM d, yyyy" + return dueInString + specificFormatter.string(from: self) + } + + func isDateInNextWeek(date: Date, currentDate: Date) -> Bool { + let calendar = Calendar.current + guard let nextWeek = calendar.date(byAdding: .weekOfYear, value: 1, to: currentDate) else { return false } + let startOfNextWeek = calendar.startOfDay(for: nextWeek) + guard let endOfNextWeek = calendar.date(byAdding: .day, value: 6, to: startOfNextWeek) else { return false } + let startOfSelfDate = calendar.startOfDay(for: date) + return startOfSelfDate >= startOfNextWeek && startOfSelfDate <= endOfNextWeek } init(subtitleTime: String) { @@ -100,29 +159,34 @@ public extension Date { return totalSeconds } - func dateToString(style: DateStringStyle) -> String { + func dateToString(style: DateStringStyle, useRelativeDates: Bool, dueIn: Bool = false) -> String { let dateFormatter = DateFormatter() - dateFormatter.locale = Locale(identifier: "en_US_POSIX") - switch style { - case .courseStartsMonthDDYear: - dateFormatter.dateFormat = CoreLocalization.DateFormat.mmmDdYyyy - case .courseEndsMonthDDYear: - dateFormatter.dateFormat = CoreLocalization.DateFormat.mmmDdYyyy - case .endedMonthDay: - dateFormatter.dateFormat = CoreLocalization.DateFormat.mmmmDd - case .mmddyy: - dateFormatter.dateFormat = "dd.MM.yy" - case .monthYear: - dateFormatter.dateFormat = "MMMM yyyy" - case .startDDMonthYear: - dateFormatter.dateFormat = "dd MMM yyyy" - case .lastPost: - dateFormatter.dateFormat = CoreLocalization.DateFormat.mmmDdYyyy - case .iso8601: - dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" - case .shortWeekdayMonthDayYear: - applyShortWeekdayMonthDayYear(dateFormatter: dateFormatter) + dateFormatter.locale = .current + + if useRelativeDates { + return timeAgoDisplay(dueIn: dueIn) + } else { + switch style { + case .courseStartsMonthDDYear: + dateFormatter.dateStyle = .medium + case .courseEndsMonthDDYear: + dateFormatter.dateStyle = .medium + case .endedMonthDay: + dateFormatter.dateFormat = CoreLocalization.DateFormat.mmmmDd + case .mmddyy: + dateFormatter.dateFormat = "dd.MM.yy" + case .monthYear: + dateFormatter.dateFormat = "MMMM yyyy" + case .startDDMonthYear: + dateFormatter.dateFormat = "dd MMM yyyy" + case .lastPost: + dateFormatter.dateFormat = CoreLocalization.DateFormat.mmmDdYyyy + case .iso8601: + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ" + case .shortWeekdayMonthDayYear: + applyShortWeekdayMonthDayYear(dateFormatter: dateFormatter) + } } let date = dateFormatter.string(from: self) @@ -160,52 +224,19 @@ public extension Date { case .iso8601: return date case .shortWeekdayMonthDayYear: - return getShortWeekdayMonthDayYear(dateFormatterString: date) + return ( + dueIn ? CoreLocalization.Date.dueIn : "" + ) + getShortWeekdayMonthDayYear(dateFormatterString: date) } } private func applyShortWeekdayMonthDayYear(dateFormatter: DateFormatter) { - if isCurrentYear() { - let days = Calendar.current.dateComponents([.day], from: self, to: Date()) - if let day = days.day, (-6 ... -2).contains(day) { - dateFormatter.dateFormat = "EEEE" - } else { - dateFormatter.dateFormat = "MMMM d" - } - } else { dateFormatter.dateFormat = "MMMM d, yyyy" - } } private func getShortWeekdayMonthDayYear(dateFormatterString: String) -> String { let days = Calendar.current.dateComponents([.day], from: self, to: Date()) - - if let day = days.day { - guard isCurrentYear() else { - // It's past year or future year - return dateFormatterString - } - - switch day { - case -6...(-2): - return dateFormatterString - case 2...6: - return timeAgoDisplay() - case -1: - return CoreLocalization.tomorrow - case 1: - return CoreLocalization.yesterday - default: - if day > 6 || day < -6 { - return dateFormatterString - } else { - // It means, date is in hours past due or upcoming - return timeAgoDisplay() - } - } - } else { - return dateFormatterString - } + return dateFormatterString } func isCurrentYear() -> Bool { diff --git a/Core/Core/SwiftGen/Strings.swift b/Core/Core/SwiftGen/Strings.swift index ab42f4176..ed9aa825d 100644 --- a/Core/Core/SwiftGen/Strings.swift +++ b/Core/Core/SwiftGen/Strings.swift @@ -137,14 +137,32 @@ public enum CoreLocalization { public static let courseEnds = CoreLocalization.tr("Localizable", "DATE.COURSE_ENDS", fallback: "Course Ends") /// Course Starts public static let courseStarts = CoreLocalization.tr("Localizable", "DATE.COURSE_STARTS", fallback: "Course Starts") + /// %@ Days Ago + public static func daysAgo(_ p1: Any) -> String { + return CoreLocalization.tr("Localizable", "DATE.DAYS_AGO", String(describing: p1), fallback: "%@ Days Ago") + } + /// Due + public static let due = CoreLocalization.tr("Localizable", "DATE.DUE", fallback: "Due ") + /// Due in + public static let dueIn = CoreLocalization.tr("Localizable", "DATE.DUE_IN", fallback: "Due in ") + /// Due in %@ Days + public static func dueInDays(_ p1: Any) -> String { + return CoreLocalization.tr("Localizable", "DATE.DUE_IN_DAYS", String(describing: p1), fallback: "Due in %@ Days") + } /// Ended public static let ended = CoreLocalization.tr("Localizable", "DATE.ENDED", fallback: "Ended") /// Just now public static let justNow = CoreLocalization.tr("Localizable", "DATE.JUST_NOW", fallback: "Just now") + /// Next %@ + public static func next(_ p1: Any) -> String { + return CoreLocalization.tr("Localizable", "DATE.NEXT", String(describing: p1), fallback: "Next %@") + } /// Start public static let start = CoreLocalization.tr("Localizable", "DATE.START", fallback: "Start") /// Started public static let started = CoreLocalization.tr("Localizable", "DATE.STARTED", fallback: "Started") + /// Today + public static let today = CoreLocalization.tr("Localizable", "DATE.TODAY", fallback: "Today") } public enum DateFormat { /// MMM dd, yyyy diff --git a/Core/Core/View/Base/CourseCellView.swift b/Core/Core/View/Base/CourseCellView.swift index a996c3383..35f0c5d61 100644 --- a/Core/Core/View/Base/CourseCellView.swift +++ b/Core/Core/View/Base/CourseCellView.swift @@ -27,12 +27,12 @@ public struct CourseCellView: View { private var cellsCount: Int private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } - public init(model: CourseItem, type: CellType, index: Int, cellsCount: Int) { + public init(model: CourseItem, type: CellType, index: Int, cellsCount: Int, useRelativeDates: Bool) { self.type = type self.courseImage = model.imageURL self.courseName = model.name - self.courseStart = model.courseStart?.dateToString(style: .startDDMonthYear) ?? "" - self.courseEnd = model.courseEnd?.dateToString(style: .endedMonthDay) ?? "" + self.courseStart = model.courseStart?.dateToString(style: .startDDMonthYear, useRelativeDates: useRelativeDates) ?? "" + self.courseEnd = model.courseEnd?.dateToString(style: .endedMonthDay, useRelativeDates: useRelativeDates) ?? "" self.courseOrg = model.org self.index = Double(index) + 1 self.cellsCount = cellsCount @@ -148,10 +148,10 @@ struct CourseCellView_Previews: PreviewProvider { .ignoresSafeArea() VStack(spacing: 0) { // Divider() - CourseCellView(model: course, type: .discovery, index: 1, cellsCount: 3) + CourseCellView(model: course, type: .discovery, index: 1, cellsCount: 3, useRelativeDates: true) .previewLayout(.fixed(width: 180, height: 260)) // Divider() - CourseCellView(model: course, type: .discovery, index: 2, cellsCount: 3) + CourseCellView(model: course, type: .discovery, index: 2, cellsCount: 3, useRelativeDates: false) .previewLayout(.fixed(width: 180, height: 260)) // Divider() } diff --git a/Core/Core/en.lproj/Localizable.strings b/Core/Core/en.lproj/Localizable.strings index 1f8389f1f..b7bc68aad 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -53,6 +53,12 @@ "DATE.START" = "Start"; "DATE.STARTED" = "Started"; "DATE.JUST_NOW" = "Just now"; +"DATE.TODAY" = "Today"; +"DATE.NEXT" = "Next %@"; +"DATE.DAYS_AGO" = "%@ Days Ago"; +"DATE.DUE" = "Due "; +"DATE.DUE_IN" = "Due in "; +"DATE.DUE_IN_DAYS" = "Due in %@ Days"; "ALERT.ACCEPT" = "ACCEPT"; "ALERT.CANCEL" = "CANCEL"; diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index e5d4106b5..dc90d9bd4 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -101,7 +101,7 @@ public class CourseRepository: CourseRepositoryProtocol { public func getCourseDates(courseID: String) async throws -> CourseDates { let courseDates = try await api.requestData( CourseEndpoint.getCourseDates(courseID: courseID) - ).mapResponse(DataLayer.CourseDates.self).domain + ).mapResponse(DataLayer.CourseDates.self).domain(useRelativeDates: coreStorage.useRelativeDates) persistence.saveCourseDates(courseID: courseID, courseDates: courseDates) return courseDates } @@ -276,7 +276,7 @@ class CourseRepositoryMock: CourseRepositoryProtocol { do { let courseDates = try CourseRepository.courseDatesJSON.data(using: .utf8)!.mapResponse(DataLayer.CourseDates.self) - return courseDates.domain + return courseDates.domain(useRelativeDates: true) } catch { throw error } diff --git a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift index c88637989..841c56bbc 100644 --- a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift @@ -147,7 +147,8 @@ final class CourseDateViewModelTests: XCTestCase { linkText: nil, title: "TestBlock", extraInfo: nil, - firstComponentBlockID: "blockID1" + firstComponentBlockID: "blockID1", + useRelativeDates: true ) let block2 = CourseDateBlock( @@ -161,7 +162,8 @@ final class CourseDateViewModelTests: XCTestCase { linkText: nil, title: "TestBlock", extraInfo: nil, - firstComponentBlockID: "blockID1" + firstComponentBlockID: "blockID1", + useRelativeDates: true ) let courseDates = CourseDates( @@ -195,7 +197,8 @@ final class CourseDateViewModelTests: XCTestCase { linkText: nil, title: "TestBlock", extraInfo: nil, - firstComponentBlockID: "blockID1" + firstComponentBlockID: "blockID1", + useRelativeDates: true ) let block2 = CourseDateBlock( @@ -209,7 +212,8 @@ final class CourseDateViewModelTests: XCTestCase { linkText: nil, title: "TestBlock", extraInfo: nil, - firstComponentBlockID: "blockID1" + firstComponentBlockID: "blockID1", + useRelativeDates: true ) let courseDates = CourseDates( @@ -242,7 +246,8 @@ final class CourseDateViewModelTests: XCTestCase { linkText: nil, title: "TestAssignment", extraInfo: nil, - firstComponentBlockID: "blockID3" + firstComponentBlockID: "blockID3", + useRelativeDates: true ) XCTAssertEqual(block.blockStatus, .dueNext) @@ -260,7 +265,8 @@ final class CourseDateViewModelTests: XCTestCase { linkText: nil, title: CourseLocalization.CourseDates.today, extraInfo: nil, - firstComponentBlockID: "blockIDTest" + firstComponentBlockID: "blockIDTest", + useRelativeDates: true ) XCTAssertEqual(block.title, "Today", "Block title for 'today' should be 'Today'") @@ -278,7 +284,8 @@ final class CourseDateViewModelTests: XCTestCase { linkText: nil, title: "TestBlock", extraInfo: nil, - firstComponentBlockID: "blockIDTest" + firstComponentBlockID: "blockIDTest", + useRelativeDates: true ) XCTAssertEqual(block.blockStatus, .completed, "Block status for a completed assignment should be 'completed'") } @@ -295,7 +302,8 @@ final class CourseDateViewModelTests: XCTestCase { linkText: nil, title: "TestBlock", extraInfo: nil, - firstComponentBlockID: "blockIDTest" + firstComponentBlockID: "blockIDTest", + useRelativeDates: true ) XCTAssertEqual(block.blockStatus, .verifiedOnly, "Block status for a block without learner access should be 'verifiedOnly'") @@ -313,7 +321,8 @@ final class CourseDateViewModelTests: XCTestCase { linkText: nil, title: "TestBlock", extraInfo: nil, - firstComponentBlockID: "blockIDTest" + firstComponentBlockID: "blockIDTest", + useRelativeDates: true ) XCTAssertEqual(block.blockStatus, .pastDue, "Block status for a past due assignment should be 'pastDue'") @@ -331,7 +340,8 @@ final class CourseDateViewModelTests: XCTestCase { linkText: nil, title: "TestBlock", extraInfo: nil, - firstComponentBlockID: "blockIDTest" + firstComponentBlockID: "blockIDTest", + useRelativeDates: true ) XCTAssertTrue(availableAssignment.canShowLink, "Available assignments should be hyperlinked.") } @@ -348,7 +358,8 @@ final class CourseDateViewModelTests: XCTestCase { linkText: nil, title: "TestBlock", extraInfo: nil, - firstComponentBlockID: "blockIDTest" + firstComponentBlockID: "blockIDTest", + useRelativeDates: true ) XCTAssertTrue(block.isAssignment) @@ -366,7 +377,8 @@ final class CourseDateViewModelTests: XCTestCase { linkText: nil, title: "TestBlock", extraInfo: nil, - firstComponentBlockID: "blockIDTest" + firstComponentBlockID: "blockIDTest", + useRelativeDates: true ) XCTAssertEqual(block.blockStatus, BlockStatus.courseStartDate) @@ -384,7 +396,8 @@ final class CourseDateViewModelTests: XCTestCase { linkText: nil, title: "TestBlock", extraInfo: nil, - firstComponentBlockID: "blockIDTest" + firstComponentBlockID: "blockIDTest", + useRelativeDates: true ) XCTAssertEqual(block.blockStatus, BlockStatus.courseEndDate) @@ -402,7 +415,8 @@ final class CourseDateViewModelTests: XCTestCase { linkText: nil, title: "TestBlock", extraInfo: nil, - firstComponentBlockID: "blockIDTest" + firstComponentBlockID: "blockIDTest", + useRelativeDates: true ) XCTAssertTrue(block.isVerifiedOnly, "Block should be identified as 'verified only' when the learner has no access.") @@ -420,7 +434,8 @@ final class CourseDateViewModelTests: XCTestCase { linkText: nil, title: "TestBlock", extraInfo: nil, - firstComponentBlockID: "blockIDTest" + firstComponentBlockID: "blockIDTest", + useRelativeDates: true ) XCTAssertTrue(block.isComplete, "Block should be marked as completed.") @@ -438,7 +453,8 @@ final class CourseDateViewModelTests: XCTestCase { linkText: nil, title: "TestBlock", extraInfo: nil, - firstComponentBlockID: "blockIDTest" + firstComponentBlockID: "blockIDTest", + useRelativeDates: true ) XCTAssertEqual(block.blockStatus, .unreleased, "Block status should be set to 'unreleased' for unreleased assignments.") @@ -456,7 +472,8 @@ final class CourseDateViewModelTests: XCTestCase { linkText: nil, title: "TestBlock", extraInfo: nil, - firstComponentBlockID: "blockIDTest" + firstComponentBlockID: "blockIDTest", + useRelativeDates: true ) XCTAssertEqual(block.blockStatus, .verifiedOnly) @@ -475,7 +492,8 @@ final class CourseDateViewModelTests: XCTestCase { linkText: nil, title: "TestBlock", extraInfo: nil, - firstComponentBlockID: "blockIDTest" + firstComponentBlockID: "blockIDTest", + useRelativeDates: true ) XCTAssertEqual(block.blockStatus, .unreleased) diff --git a/Dashboard/Dashboard/Presentation/AllCoursesView.swift b/Dashboard/Dashboard/Presentation/AllCoursesView.swift index 332ae13c4..4960ae42a 100644 --- a/Dashboard/Dashboard/Presentation/AllCoursesView.swift +++ b/Dashboard/Dashboard/Presentation/AllCoursesView.swift @@ -58,6 +58,7 @@ public struct AllCoursesView: View { .disabled(viewModel.fetchInProgress) .frameLimit(width: proxy.size.width) if let myEnrollments = viewModel.myEnrollments { + let useRelativeDates = viewModel.storage.useRelativeDates LazyVGrid(columns: columns(), spacing: 15) { ForEach( Array(myEnrollments.courses.enumerated()), @@ -88,7 +89,8 @@ public struct AllCoursesView: View { courseStartDate: course.courseStart, courseEndDate: course.courseEnd, hasAccess: course.hasAccess, - showProgress: true + showProgress: true, + useRelativeDates: useRelativeDates ).padding(8) }) .accessibilityIdentifier("course_item") @@ -196,7 +198,8 @@ struct AllCoursesView_Previews: PreviewProvider { let vm = AllCoursesViewModel( interactor: DashboardInteractor.mock, connectivity: Connectivity(), - analytics: DashboardAnalyticsMock() + analytics: DashboardAnalyticsMock(), + storage: CoreStorageMock() ) AllCoursesView(viewModel: vm, router: DashboardRouterMock()) diff --git a/Dashboard/Dashboard/Presentation/AllCoursesViewModel.swift b/Dashboard/Dashboard/Presentation/AllCoursesViewModel.swift index 750c0936b..439f329f7 100644 --- a/Dashboard/Dashboard/Presentation/AllCoursesViewModel.swift +++ b/Dashboard/Dashboard/Presentation/AllCoursesViewModel.swift @@ -29,6 +29,7 @@ public class AllCoursesViewModel: ObservableObject { } let connectivity: ConnectivityProtocol + let storage: CoreStorage private let interactor: DashboardInteractorProtocol private let analytics: DashboardAnalytics private var onCourseEnrolledCancellable: AnyCancellable? @@ -36,11 +37,13 @@ public class AllCoursesViewModel: ObservableObject { public init( interactor: DashboardInteractorProtocol, connectivity: ConnectivityProtocol, - analytics: DashboardAnalytics + analytics: DashboardAnalytics, + storage: CoreStorage ) { self.interactor = interactor self.connectivity = connectivity self.analytics = analytics + self.storage = storage onCourseEnrolledCancellable = NotificationCenter.default .publisher(for: .onCourseEnrolled) diff --git a/Dashboard/Dashboard/Presentation/Elements/CourseCardView.swift b/Dashboard/Dashboard/Presentation/Elements/CourseCardView.swift index 2c93c7d33..e493c00d5 100644 --- a/Dashboard/Dashboard/Presentation/Elements/CourseCardView.swift +++ b/Dashboard/Dashboard/Presentation/Elements/CourseCardView.swift @@ -20,6 +20,7 @@ struct CourseCardView: View { private let courseEndDate: Date? private let hasAccess: Bool private let showProgress: Bool + private let useRelativeDates: Bool init( courseName: String, @@ -29,7 +30,8 @@ struct CourseCardView: View { courseStartDate: Date?, courseEndDate: Date?, hasAccess: Bool, - showProgress: Bool + showProgress: Bool, + useRelativeDates: Bool ) { self.courseName = courseName self.courseImage = courseImage @@ -39,6 +41,7 @@ struct CourseCardView: View { self.courseEndDate = courseEndDate self.hasAccess = hasAccess self.showProgress = showProgress + self.useRelativeDates = useRelativeDates } var body: some View { @@ -85,12 +88,12 @@ struct CourseCardView: View { private var courseTitle: some View { VStack(alignment: .leading, spacing: 3) { if let courseEndDate { - Text(courseEndDate.dateToString(style: .courseEndsMonthDDYear)) + Text(courseEndDate.dateToString(style: .courseEndsMonthDDYear, useRelativeDates: useRelativeDates)) .font(Theme.Fonts.labelSmall) .foregroundStyle(Theme.Colors.textSecondaryLight) .multilineTextAlignment(.leading) } else if let courseStartDate { - Text(courseStartDate.dateToString(style: .courseStartsMonthDDYear)) + Text(courseStartDate.dateToString(style: .courseStartsMonthDDYear, useRelativeDates: useRelativeDates)) .font(Theme.Fonts.labelSmall) .foregroundStyle(Theme.Colors.textSecondaryLight) .multilineTextAlignment(.leading) @@ -119,7 +122,8 @@ struct CourseCardView: View { courseStartDate: nil, courseEndDate: Date(), hasAccess: true, - showProgress: true + showProgress: true, + useRelativeDates: true ).frame(width: 170) } #endif diff --git a/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift b/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift index fc18526a9..83680dc7c 100644 --- a/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift +++ b/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift @@ -23,6 +23,7 @@ public struct PrimaryCardView: View { private let progressPossible: Int private let canResume: Bool private let resumeTitle: String? + private let useRelativeDates: Bool private var assignmentAction: (String?) -> Void private var openCourseAction: () -> Void private var resumeAction: () -> Void @@ -39,6 +40,7 @@ public struct PrimaryCardView: View { progressPossible: Int, canResume: Bool, resumeTitle: String?, + useRelativeDates: Bool, assignmentAction: @escaping (String?) -> Void, openCourseAction: @escaping () -> Void, resumeAction: @escaping () -> Void @@ -54,6 +56,7 @@ public struct PrimaryCardView: View { self.progressPossible = progressPossible self.canResume = canResume self.resumeTitle = resumeTitle + self.useRelativeDates = useRelativeDates self.assignmentAction = assignmentAction self.openCourseAction = openCourseAction self.resumeAction = resumeAction @@ -110,9 +113,10 @@ public struct PrimaryCardView: View { ).day ?? 0 courseButton( title: futureAssignment.title, - description: DashboardLocalization.Learn.PrimaryCard.dueDays( - futureAssignment.type, - daysRemaining + description: futureAssignment.date.dateToString( + style: .shortWeekdayMonthDayYear, + useRelativeDates: useRelativeDates, + dueIn: true ), icon: CoreAssets.chapter.swiftUIImage, selected: false, @@ -125,7 +129,7 @@ public struct PrimaryCardView: View { courseButton( title: DashboardLocalization.Learn.PrimaryCard.futureAssignments( futureAssignments.count, - firtsData.date.dateToString(style: .lastPost) + firtsData.date.dateToString(style: .lastPost, useRelativeDates: useRelativeDates) ), description: nil, icon: CoreAssets.chapter.swiftUIImage, @@ -235,11 +239,11 @@ public struct PrimaryCardView: View { .foregroundStyle(Theme.Colors.textPrimary) .lineLimit(3) if let courseEndDate { - Text(courseEndDate.dateToString(style: .courseEndsMonthDDYear)) + Text(courseEndDate.dateToString(style: .courseEndsMonthDDYear, useRelativeDates: useRelativeDates)) .font(Theme.Fonts.labelMedium) .foregroundStyle(Theme.Colors.textSecondaryLight) } else if let courseStartDate { - Text(courseStartDate.dateToString(style: .courseStartsMonthDDYear)) + Text(courseStartDate.dateToString(style: .courseStartsMonthDDYear, useRelativeDates: useRelativeDates)) .font(Theme.Fonts.labelMedium) .foregroundStyle(Theme.Colors.textSecondaryLight) } @@ -261,13 +265,23 @@ struct PrimaryCardView_Previews: PreviewProvider { courseImage: "https://thumbs.dreamstime.com/b/logo-edx-samsung-tablet-edx-massive-open-online-course-mooc-provider-hosts-online-university-level-courses-wide-117763805.jpg", courseStartDate: nil, courseEndDate: Date(), - futureAssignments: [], + futureAssignments: [ + Assignment( + type: "Lesson", + title: "HomeWork", + description: "Some description", + date: Date().addingTimeInterval(64000 * 3), + complete: false, + firstComponentBlockId: "123" + ) + ], pastAssignments: [], progressEarned: 10, progressPossible: 45, canResume: true, resumeTitle: "Course Chapter 1", - assignmentAction: {_ in }, + useRelativeDates: false, + assignmentAction: { _ in }, openCourseAction: {}, resumeAction: {} ) diff --git a/Dashboard/Dashboard/Presentation/ListDashboardView.swift b/Dashboard/Dashboard/Presentation/ListDashboardView.swift index 90a3e5eba..a2678b3ff 100644 --- a/Dashboard/Dashboard/Presentation/ListDashboardView.swift +++ b/Dashboard/Dashboard/Presentation/ListDashboardView.swift @@ -54,14 +54,15 @@ public struct ListDashboardView: View { if viewModel.courses.isEmpty && !viewModel.fetchInProgress { EmptyPageIcon() } else { + let useRelativeDates = viewModel.storage.useRelativeDates ForEach(Array(viewModel.courses.enumerated()), id: \.offset) { index, course in - CourseCellView( model: course, type: .dashboard, index: index, - cellsCount: viewModel.courses.count + cellsCount: viewModel.courses.count, + useRelativeDates: useRelativeDates ) .padding(.horizontal, 20) .listRowBackground(Color.clear) @@ -157,7 +158,8 @@ struct ListDashboardView_Previews: PreviewProvider { let vm = ListDashboardViewModel( interactor: DashboardInteractor.mock, connectivity: Connectivity(), - analytics: DashboardAnalyticsMock() + analytics: DashboardAnalyticsMock(), + storage: CoreStorageMock() ) let router = DashboardRouterMock() diff --git a/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift b/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift index f962824e9..112865e86 100644 --- a/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift +++ b/Dashboard/Dashboard/Presentation/ListDashboardViewModel.swift @@ -29,15 +29,18 @@ public class ListDashboardViewModel: ObservableObject { let connectivity: ConnectivityProtocol private let interactor: DashboardInteractorProtocol private let analytics: DashboardAnalytics + let storage: CoreStorage private var onCourseEnrolledCancellable: AnyCancellable? private var refreshEnrollmentsCancellable: AnyCancellable? public init(interactor: DashboardInteractorProtocol, connectivity: ConnectivityProtocol, - analytics: DashboardAnalytics) { + analytics: DashboardAnalytics, + storage: CoreStorage) { self.interactor = interactor self.connectivity = connectivity self.analytics = analytics + self.storage = storage onCourseEnrolledCancellable = NotificationCenter.default .publisher(for: .onCourseEnrolled) diff --git a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift index 7ac041110..d182a6a44 100644 --- a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift +++ b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift @@ -75,6 +75,7 @@ public struct PrimaryCourseDashboardView: View { progressPossible: primary.progressPossible, canResume: primary.lastVisitedBlockID != nil, resumeTitle: primary.resumeTitle, + useRelativeDates: viewModel.storage.useRelativeDates, assignmentAction: { lastVisitedBlockID in router.showCourseScreens( courseID: primary.courseID, @@ -199,6 +200,7 @@ public struct PrimaryCourseDashboardView: View { @ViewBuilder private func courses(_ enrollments: PrimaryEnrollment) -> some View { + let useRelativeDates = viewModel.storage.useRelativeDates ForEach( Array(enrollments.courses.enumerated()), id: \.offset @@ -228,7 +230,8 @@ public struct PrimaryCourseDashboardView: View { courseStartDate: nil, courseEndDate: nil, hasAccess: course.hasAccess, - showProgress: false + showProgress: false, + useRelativeDates: useRelativeDates ).frame(width: idiom == .pad ? nil : 120) } ) @@ -330,7 +333,8 @@ struct PrimaryCourseDashboardView_Previews: PreviewProvider { interactor: DashboardInteractor.mock, connectivity: Connectivity(), analytics: DashboardAnalyticsMock(), - config: ConfigMock() + config: ConfigMock(), + storage: CoreStorageMock() ) PrimaryCourseDashboardView( diff --git a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift index 60ffe9c0b..f1a74f773 100644 --- a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift +++ b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardViewModel.swift @@ -30,6 +30,7 @@ public class PrimaryCourseDashboardViewModel: ObservableObject { private let interactor: DashboardInteractorProtocol private let analytics: DashboardAnalytics let config: ConfigProtocol + let storage: CoreStorage private var cancellables = Set() private let ipadPageSize = 7 @@ -39,12 +40,14 @@ public class PrimaryCourseDashboardViewModel: ObservableObject { interactor: DashboardInteractorProtocol, connectivity: ConnectivityProtocol, analytics: DashboardAnalytics, - config: ConfigProtocol + config: ConfigProtocol, + storage: CoreStorage ) { self.interactor = interactor self.connectivity = connectivity self.analytics = analytics self.config = config + self.storage = storage let enrollmentPublisher = NotificationCenter.default.publisher(for: .onCourseEnrolled) let completionPublisher = NotificationCenter.default.publisher(for: .onblockCompletionRequested) diff --git a/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift b/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift index 1d3ab3db9..a5fb4e9b7 100644 --- a/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift +++ b/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift @@ -18,7 +18,12 @@ final class ListDashboardViewModelTests: XCTestCase { let interactor = DashboardInteractorProtocolMock() let connectivity = ConnectivityProtocolMock() let analytics = DashboardAnalyticsMock() - let viewModel = ListDashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) + let viewModel = ListDashboardViewModel( + interactor: interactor, + connectivity: connectivity, + analytics: analytics, + storage: CoreStorageMock() + ) let items = [ CourseItem(name: "Test", @@ -67,7 +72,12 @@ final class ListDashboardViewModelTests: XCTestCase { let interactor = DashboardInteractorProtocolMock() let connectivity = ConnectivityProtocolMock() let analytics = DashboardAnalyticsMock() - let viewModel = ListDashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) + let viewModel = ListDashboardViewModel( + interactor: interactor, + connectivity: connectivity, + analytics: analytics, + storage: CoreStorageMock() + ) let items = [ CourseItem(name: "Test", @@ -116,7 +126,12 @@ final class ListDashboardViewModelTests: XCTestCase { let interactor = DashboardInteractorProtocolMock() let connectivity = ConnectivityProtocolMock() let analytics = DashboardAnalyticsMock() - let viewModel = ListDashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) + let viewModel = ListDashboardViewModel( + interactor: interactor, + connectivity: connectivity, + analytics: analytics, + storage: CoreStorageMock() + ) Given(connectivity, .isInternetAvaliable(getter: true)) Given(interactor, .getEnrollments(page: .any, willThrow: NoCachedDataError()) ) @@ -134,7 +149,12 @@ final class ListDashboardViewModelTests: XCTestCase { let interactor = DashboardInteractorProtocolMock() let connectivity = ConnectivityProtocolMock() let analytics = DashboardAnalyticsMock() - let viewModel = ListDashboardViewModel(interactor: interactor, connectivity: connectivity, analytics: analytics) + let viewModel = ListDashboardViewModel( + interactor: interactor, + connectivity: connectivity, + analytics: analytics, + storage: CoreStorageMock() + ) Given(connectivity, .isInternetAvaliable(getter: true)) Given(interactor, .getEnrollments(page: .any, willThrow: NSError()) ) diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift index b8d9aa860..5816da080 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift @@ -106,12 +106,14 @@ public struct DiscoveryView: View { .padding(.bottom, 20) Spacer() }.padding(.leading, 10) + let useRelativeDates = viewModel.storage.useRelativeDates ForEach(Array(viewModel.courses.enumerated()), id: \.offset) { index, course in CourseCellView( model: course, type: .discovery, index: index, - cellsCount: viewModel.courses.count + cellsCount: viewModel.courses.count, + useRelativeDates: useRelativeDates ).padding(.horizontal, 24) .onAppear { Task { diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryViewModel.swift b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryViewModel.swift index 8f64f458e..9c091f847 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryViewModel.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryViewModel.swift @@ -37,7 +37,7 @@ public class DiscoveryViewModel: ObservableObject { let connectivity: ConnectivityProtocol private let interactor: DiscoveryInteractorProtocol private let analytics: DiscoveryAnalytics - private let storage: CoreStorage + let storage: CoreStorage public init( router: DiscoveryRouter, diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift index 99d7ecea9..531a7db3e 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift @@ -110,12 +110,16 @@ public struct SearchView: View { LazyVStack { let searchResults = viewModel.searchResults.enumerated() + let useRelativeDates = viewModel.storage.useRelativeDates ForEach( Array(searchResults), id: \.offset) { index, course in - CourseCellView(model: course, - type: .discovery, - index: index, - cellsCount: viewModel.searchResults.count) + CourseCellView( + model: course, + type: .discovery, + index: index, + cellsCount: viewModel.searchResults.count, + useRelativeDates: useRelativeDates + ) .padding(.horizontal, 24) .onAppear { Task { @@ -219,7 +223,8 @@ struct SearchView_Previews: PreviewProvider { interactor: DiscoveryInteractor.mock, connectivity: Connectivity(), router: router, - analytics: DiscoveryAnalyticsMock(), + analytics: DiscoveryAnalyticsMock(), + storage: CoreStorageMock(), debounce: .searchDebounce ) diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/SearchViewModel.swift b/Discovery/Discovery/Presentation/NativeDiscovery/SearchViewModel.swift index 8f0c6ff1c..76f3ea137 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/SearchViewModel.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/SearchViewModel.swift @@ -32,6 +32,7 @@ public class SearchViewModel: ObservableObject { let router: DiscoveryRouter let analytics: DiscoveryAnalytics + let storage: CoreStorage private let interactor: DiscoveryInteractorProtocol let connectivity: ConnectivityProtocol @@ -39,12 +40,14 @@ public class SearchViewModel: ObservableObject { connectivity: ConnectivityProtocol, router: DiscoveryRouter, analytics: DiscoveryAnalytics, + storage: CoreStorage, debounce: Debounce ) { self.interactor = interactor self.connectivity = connectivity self.router = router self.analytics = analytics + self.storage = storage self.debounce = debounce $searchText diff --git a/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift b/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift index e1596add9..3aa8e9394 100644 --- a/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift +++ b/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift @@ -31,7 +31,8 @@ final class SearchViewModelTests: XCTestCase { interactor: interactor, connectivity: connectivity, router: router, - analytics: analytics, + analytics: analytics, + storage: CoreStorageMock(), debounce: .test ) @@ -94,6 +95,7 @@ final class SearchViewModelTests: XCTestCase { connectivity: connectivity, router: router, analytics: analytics, + storage: CoreStorageMock(), debounce: .test ) @@ -122,6 +124,7 @@ final class SearchViewModelTests: XCTestCase { connectivity: connectivity, router: router, analytics: analytics, + storage: CoreStorageMock(), debounce: .test ) @@ -155,6 +158,7 @@ final class SearchViewModelTests: XCTestCase { connectivity: connectivity, router: router, analytics: analytics, + storage: CoreStorageMock(), debounce: .test ) diff --git a/Discussion/Discussion/Domain/Model/UserThread.swift b/Discussion/Discussion/Domain/Model/UserThread.swift index 4b33833a5..f28e2a532 100644 --- a/Discussion/Discussion/Domain/Model/UserThread.swift +++ b/Discussion/Discussion/Domain/Model/UserThread.swift @@ -87,19 +87,24 @@ public struct UserThread { } public extension UserThread { - func discussionPost(action: @escaping () -> Void) -> DiscussionPost { - return DiscussionPost(id: id, - title: title, - replies: commentCount, - lastPostDate: updatedAt, - lastPostDateFormatted: updatedAt.dateToString(style: .lastPost), - isFavorite: following, - type: type, - unreadCommentCount: unreadCommentCount, - action: action, - hasEndorsed: hasEndorsed, - voteCount: voteCount, - numPages: numPages) + func discussionPost(useRelativeDates: Bool, action: @escaping () -> Void) -> DiscussionPost { + return DiscussionPost( + id: id, + title: title, + replies: commentCount, + lastPostDate: updatedAt, + lastPostDateFormatted: updatedAt.dateToString( + style: .lastPost, + useRelativeDates: useRelativeDates + ), + isFavorite: following, + type: type, + unreadCommentCount: unreadCommentCount, + action: action, + hasEndorsed: hasEndorsed, + voteCount: voteCount, + numPages: numPages + ) } } diff --git a/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift b/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift index 4a955aa2e..eacf00109 100644 --- a/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Base/BaseResponsesViewModel.swift @@ -44,16 +44,19 @@ public class BaseResponsesViewModel { internal let interactor: DiscussionInteractorProtocol internal let router: DiscussionRouter internal let config: ConfigProtocol + internal let storage: CoreStorage internal let addPostSubject = CurrentValueSubject(nil) init( interactor: DiscussionInteractorProtocol, router: DiscussionRouter, - config: ConfigProtocol + config: ConfigProtocol, + storage: CoreStorage ) { self.interactor = interactor self.router = router self.config = config + self.storage = storage } @MainActor diff --git a/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift b/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift index 7d8c8b155..f4c56c102 100644 --- a/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift +++ b/Discussion/Discussion/Presentation/Comments/Base/CommentCell.swift @@ -14,6 +14,7 @@ public struct CommentCell: View { private let comment: Post private let addCommentAvailable: Bool + private let useRelativeDates: Bool private var onAvatarTap: ((String) -> Void) private var onLikeTap: (() -> Void) private var onReportTap: (() -> Void) @@ -26,6 +27,7 @@ public struct CommentCell: View { public init( comment: Post, addCommentAvailable: Bool, + useRelativeDates: Bool, leftLineEnabled: Bool = false, onAvatarTap: @escaping (String) -> Void, onLikeTap: @escaping () -> Void, @@ -35,6 +37,7 @@ public struct CommentCell: View { ) { self.comment = comment self.addCommentAvailable = addCommentAvailable + self.useRelativeDates = useRelativeDates self.leftLineEnabled = leftLineEnabled self.onAvatarTap = onAvatarTap self.onLikeTap = onLikeTap @@ -59,7 +62,7 @@ public struct CommentCell: View { VStack(alignment: .leading) { Text(comment.authorName) .font(Theme.Fonts.titleSmall) - Text(comment.postDate.dateToString(style: .lastPost)) + Text(comment.postDate.dateToString(style: .lastPost, useRelativeDates: useRelativeDates)) .font(Theme.Fonts.labelSmall) .foregroundColor(Theme.Colors.textSecondary) } @@ -179,15 +182,19 @@ struct CommentView_Previews: PreviewProvider { CommentCell( comment: comment, addCommentAvailable: true, + useRelativeDates: true, leftLineEnabled: false, - onAvatarTap: {_ in}, + onAvatarTap: { + _ in + }, onLikeTap: {}, onReportTap: {}, onCommentsTap: {}, onFetchMore: {}) CommentCell( comment: comment, - addCommentAvailable: true, + addCommentAvailable: true, + useRelativeDates: true, leftLineEnabled: false, onAvatarTap: {_ in}, onLikeTap: {}, @@ -202,7 +209,8 @@ struct CommentView_Previews: PreviewProvider { VStack(spacing: 0) { CommentCell( comment: comment, - addCommentAvailable: true, + addCommentAvailable: true, + useRelativeDates: true, leftLineEnabled: false, onAvatarTap: {_ in}, onLikeTap: {}, @@ -211,7 +219,8 @@ struct CommentView_Previews: PreviewProvider { onFetchMore: {}) CommentCell( comment: comment, - addCommentAvailable: true, + addCommentAvailable: true, + useRelativeDates: true, leftLineEnabled: false, onAvatarTap: {_ in}, onLikeTap: {}, diff --git a/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift b/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift index 3594f26c5..3fce895d6 100644 --- a/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift +++ b/Discussion/Discussion/Presentation/Comments/Base/ParentCommentView.swift @@ -14,6 +14,7 @@ public struct ParentCommentView: View { private let comments: Post private var isThread: Bool + private let useRelativeDates: Bool private var onAvatarTap: ((String) -> Void) private var onLikeTap: (() -> Void) private var onReportTap: (() -> Void) @@ -24,6 +25,7 @@ public struct ParentCommentView: View { public init( comments: Post, isThread: Bool, + useRelativeDates: Bool, onAvatarTap: @escaping (String) -> Void, onLikeTap: @escaping () -> Void, onReportTap: @escaping () -> Void, @@ -31,6 +33,7 @@ public struct ParentCommentView: View { ) { self.comments = comments self.isThread = isThread + self.useRelativeDates = useRelativeDates self.onAvatarTap = onAvatarTap self.onLikeTap = onLikeTap self.onReportTap = onReportTap @@ -55,7 +58,7 @@ public struct ParentCommentView: View { .font(Theme.Fonts.titleMedium) .foregroundColor(Theme.Colors.textPrimary) Text(comments.postDate - .dateToString(style: .lastPost)) + .dateToString(style: .lastPost, useRelativeDates: useRelativeDates)) .font(Theme.Fonts.labelSmall) .foregroundColor(Theme.Colors.textSecondaryLight) } @@ -169,7 +172,8 @@ struct ParentCommentView_Previews: PreviewProvider { return VStack { ParentCommentView( comments: comment, - isThread: true, + isThread: true, + useRelativeDates: true, onAvatarTap: {_ in}, onLikeTap: {}, onReportTap: {}, diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index d4dab6464..91a5d9dda 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -59,7 +59,9 @@ public struct ResponsesView: View { if let comments = viewModel.postComments { ParentCommentView( comments: comments, - isThread: false, onAvatarTap: { username in + isThread: false, + useRelativeDates: viewModel.storage.useRelativeDates, + onAvatarTap: { username in viewModel.router.showUserDetails(username: username) }, onLikeTap: { @@ -99,12 +101,15 @@ public struct ResponsesView: View { .padding(.leading, 24) .font(Theme.Fonts.titleMedium) .foregroundColor(Theme.Colors.textPrimary) + let useRelativeDates = viewModel.storage.useRelativeDates ForEach( Array(comments.comments.enumerated()), id: \.offset ) { index, comment in CommentCell( comment: comment, - addCommentAvailable: false, leftLineEnabled: true, + addCommentAvailable: false, + useRelativeDates: useRelativeDates, + leftLineEnabled: true, onAvatarTap: { username in viewModel.router.showUserDetails(username: username) }, @@ -238,6 +243,7 @@ struct ResponsesView_Previews: PreviewProvider { interactor: DiscussionInteractor(repository: DiscussionRepositoryMock()), router: DiscussionRouterMock(), config: ConfigMock(), + storage: CoreStorageMock(), threadStateSubject: .init(nil) ) let post = Post( diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift index c8992fd8a..b73697264 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesViewModel.swift @@ -20,10 +20,11 @@ public class ResponsesViewModel: BaseResponsesViewModel, ObservableObject { interactor: DiscussionInteractorProtocol, router: DiscussionRouter, config: ConfigProtocol, + storage: CoreStorage, threadStateSubject: CurrentValueSubject ) { self.threadStateSubject = threadStateSubject - super.init(interactor: interactor, router: router, config: config) + super.init(interactor: interactor, router: router, config: config, storage: storage) } func generateCommentsResponses(comments: [UserComment], parentComment: Post) -> Post? { diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index b764ed0d6..1d39962f2 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -44,6 +44,7 @@ public struct ThreadView: View { ParentCommentView( comments: comments, isThread: true, + useRelativeDates: viewModel.storage.useRelativeDates, onAvatarTap: { username in viewModel.router.showUserDetails(username: username) }, @@ -92,11 +93,13 @@ public struct ThreadView: View { .padding(.leading, 24) .font(Theme.Fonts.titleMedium) .foregroundColor(Theme.Colors.textPrimary) + let useRelativeDates = viewModel.storage.useRelativeDates ForEach(Array(comments.comments.enumerated()), id: \.offset) { index, comment in CommentCell( comment: comment, addCommentAvailable: true, + useRelativeDates: useRelativeDates, onAvatarTap: { username in viewModel.router.showUserDetails(username: username) }, @@ -281,10 +284,13 @@ struct CommentsView_Previews: PreviewProvider { abuseFlagged: true, hasEndorsed: true, numPages: 3) - let vm = ThreadViewModel(interactor: DiscussionInteractor.mock, - router: DiscussionRouterMock(), - config: ConfigMock(), - postStateSubject: .init(nil)) + let vm = ThreadViewModel( + interactor: DiscussionInteractor.mock, + router: DiscussionRouterMock(), + config: ConfigMock(), + storage: CoreStorageMock(), + postStateSubject: .init(nil) + ) ThreadView(thread: userThread, viewModel: vm) .preferredColorScheme(.light) diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift index 2fb75b60f..452ea98f2 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadViewModel.swift @@ -16,17 +16,17 @@ 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, config: ConfigProtocol, + storage: CoreStorage, postStateSubject: CurrentValueSubject ) { self.postStateSubject = postStateSubject - super.init(interactor: interactor, router: router, config: config) + super.init(interactor: interactor, router: router, config: config, storage: storage) cancellable = threadStateSubject .receive(on: RunLoop.main) diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift index c9fd88dd7..440666e85 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift @@ -195,7 +195,8 @@ struct DiscussionSearchTopicsView_Previews: PreviewProvider { static var previews: some View { let vm = DiscussionSearchTopicsViewModel( courseID: "123", - interactor: DiscussionInteractor.mock, + interactor: DiscussionInteractor.mock, + storage: CoreStorageMock(), router: DiscussionRouterMock(), debounce: .searchDebounce ) diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift index 95afa250b..83b7b2741 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModel.swift @@ -39,16 +39,19 @@ public class DiscussionSearchTopicsViewModel: ObservableObject { let router: DiscussionRouter private let interactor: DiscussionInteractorProtocol + private let storage: CoreStorage private let debounce: Debounce public init( courseID: String, interactor: DiscussionInteractorProtocol, + storage: CoreStorage, router: DiscussionRouter, debounce: Debounce ) { self.courseID = courseID self.interactor = interactor + self.storage = storage self.router = router self.debounce = debounce @@ -157,7 +160,7 @@ public class DiscussionSearchTopicsViewModel: ObservableObject { private func generatePosts(threads: [UserThread]) -> [DiscussionPost] { var result: [DiscussionPost] = [] for thread in threads { - result.append(thread.discussionPost(action: { [weak self] in + result.append(thread.discussionPost(useRelativeDates: storage.useRelativeDates, action: { [weak self] in guard let self else { return } self.router.showThread( thread: thread, diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index 9664d1f19..1ab2fc0fb 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -326,7 +326,8 @@ struct PostsView_Previews: PreviewProvider { let vm = PostsViewModel( interactor: DiscussionInteractor.mock, router: router, - config: ConfigMock() + config: ConfigMock(), + storage: CoreStorageMock() ) PostsView(courseID: "course_id", diff --git a/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift b/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift index b1676c70b..8115ea0f2 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsViewModel.swift @@ -80,17 +80,20 @@ public class PostsViewModel: ObservableObject { private let interactor: DiscussionInteractorProtocol private let router: DiscussionRouter private let config: ConfigProtocol + private let storage: CoreStorage internal let postStateSubject = CurrentValueSubject(nil) private var cancellable: AnyCancellable? public init( interactor: DiscussionInteractorProtocol, router: DiscussionRouter, - config: ConfigProtocol + config: ConfigProtocol, + storage: CoreStorage ) { self.interactor = interactor self.router = router self.config = config + self.storage = storage cancellable = postStateSubject .receive(on: RunLoop.main) @@ -130,17 +133,24 @@ public class PostsViewModel: ObservableObject { var result: [DiscussionPost] = [] if let threads = threads?.threads { for thread in threads { - result.append(thread.discussionPost(action: { [weak self] in - guard let self, let actualThread = self.threads.threads - .first(where: {$0.id == thread.id }) else { return } - - self.router.showThread( - thread: actualThread, - postStateSubject: self.postStateSubject, - isBlackedOut: self.isBlackedOut ?? false, - animated: true + result.append( + thread.discussionPost( + useRelativeDates: storage.useRelativeDates, + action: { + [weak self] in + guard let self, + let actualThread = self.threads.threads + .first(where: {$0.id == thread.id }) else { return } + + self.router.showThread( + thread: actualThread, + postStateSubject: self.postStateSubject, + isBlackedOut: self.isBlackedOut ?? false, + animated: true + ) + } ) - })) + ) } } diff --git a/Discussion/DiscussionTests/Presentation/Comment/Base/BaseResponsesViewModelTests.swift b/Discussion/DiscussionTests/Presentation/Comment/Base/BaseResponsesViewModelTests.swift index b6940c034..d862278fa 100644 --- a/Discussion/DiscussionTests/Presentation/Comment/Base/BaseResponsesViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/Comment/Base/BaseResponsesViewModelTests.swift @@ -50,11 +50,27 @@ final class BaseResponsesViewModelTests: XCTestCase { abuseFlagged: false, closed: false) + var interactor: DiscussionInteractorProtocolMock! + var router: DiscussionRouterMock! + var config: ConfigMock! + var viewModel: BaseResponsesViewModel! + + override func setUp() async throws { + try await super.setUp() + + interactor = DiscussionInteractorProtocolMock() + router = DiscussionRouterMock() + config = ConfigMock() + viewModel = BaseResponsesViewModel( + interactor: interactor, + router: router, + config: config, + storage: CoreStorageMock() + ) + } + func testVoteThreadSuccess() async throws { - let interactor = DiscussionInteractorProtocolMock() - let router = DiscussionRouterMock() - let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) + var result = false viewModel.postComments = post @@ -73,10 +89,7 @@ final class BaseResponsesViewModelTests: XCTestCase { } func testVoteResponseSuccess() async throws { - let interactor = DiscussionInteractorProtocolMock() - let router = DiscussionRouterMock() - let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) + var result = false @@ -97,10 +110,7 @@ final class BaseResponsesViewModelTests: XCTestCase { } func testVoteParentThreadSuccess() async throws { - let interactor = DiscussionInteractorProtocolMock() - let router = DiscussionRouterMock() - let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) + var result = false @@ -120,10 +130,7 @@ final class BaseResponsesViewModelTests: XCTestCase { } func testVoteParentResponseSuccess() async throws { - let interactor = DiscussionInteractorProtocolMock() - let router = DiscussionRouterMock() - let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) + var result = false @@ -145,10 +152,7 @@ final class BaseResponsesViewModelTests: XCTestCase { } func testVoteNoInternetError() async throws { - let interactor = DiscussionInteractorProtocolMock() - let router = DiscussionRouterMock() - let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) + var result = false @@ -167,10 +171,7 @@ final class BaseResponsesViewModelTests: XCTestCase { } func testVoteUnknownError() async throws { - let interactor = DiscussionInteractorProtocolMock() - let router = DiscussionRouterMock() - let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) + var result = false @@ -187,10 +188,6 @@ final class BaseResponsesViewModelTests: XCTestCase { } func testFlagThreadSuccess() async throws { - let interactor = DiscussionInteractorProtocolMock() - let router = DiscussionRouterMock() - let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) var result = false @@ -210,10 +207,6 @@ final class BaseResponsesViewModelTests: XCTestCase { } func testFlagCommentSuccess() async throws { - let interactor = DiscussionInteractorProtocolMock() - let router = DiscussionRouterMock() - let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) var result = false @@ -233,10 +226,6 @@ final class BaseResponsesViewModelTests: XCTestCase { } func testFlagNoInternetError() async throws { - let interactor = DiscussionInteractorProtocolMock() - let router = DiscussionRouterMock() - let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) var result = false @@ -255,10 +244,6 @@ final class BaseResponsesViewModelTests: XCTestCase { } func testFlagUnknownError() async throws { - let interactor = DiscussionInteractorProtocolMock() - let router = DiscussionRouterMock() - let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) var result = false @@ -275,10 +260,6 @@ final class BaseResponsesViewModelTests: XCTestCase { } func testFollowThreadSuccess() async throws { - let interactor = DiscussionInteractorProtocolMock() - let router = DiscussionRouterMock() - let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) var result = false @@ -298,10 +279,6 @@ final class BaseResponsesViewModelTests: XCTestCase { } func testFollowThreadNoInternetError() async throws { - let interactor = DiscussionInteractorProtocolMock() - let router = DiscussionRouterMock() - let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) var result = false @@ -320,10 +297,6 @@ final class BaseResponsesViewModelTests: XCTestCase { } func testFollowThreadUnknownError() async throws { - let interactor = DiscussionInteractorProtocolMock() - let router = DiscussionRouterMock() - let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) var result = false @@ -340,10 +313,6 @@ final class BaseResponsesViewModelTests: XCTestCase { } func testAddNewPost() { - let interactor = DiscussionInteractorProtocolMock() - let router = DiscussionRouterMock() - let config = ConfigMock() - let viewModel = BaseResponsesViewModel(interactor: interactor, router: router, config: config) viewModel.postComments = post diff --git a/Discussion/DiscussionTests/Presentation/Comment/ThreadViewModelTests.swift b/Discussion/DiscussionTests/Presentation/Comment/ThreadViewModelTests.swift index 7da4cdf55..97ab5f718 100644 --- a/Discussion/DiscussionTests/Presentation/Comment/ThreadViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/Comment/ThreadViewModelTests.swift @@ -212,6 +212,7 @@ final class ThreadViewModelTests: XCTestCase { let viewModel = ThreadViewModel(interactor: interactor, router: router, config: config, + storage: CoreStorageMock(), postStateSubject: .init(.readed(id: "1"))) Given(interactor, .readBody(threadID: .any, willProduce: {_ in})) @@ -241,6 +242,7 @@ final class ThreadViewModelTests: XCTestCase { let viewModel = ThreadViewModel(interactor: interactor, router: router, config: config, + storage: CoreStorageMock(), postStateSubject: .init(.readed(id: "1"))) Given(interactor, .readBody(threadID: .any, willProduce: {_ in})) @@ -270,6 +272,7 @@ final class ThreadViewModelTests: XCTestCase { let viewModel = ThreadViewModel(interactor: interactor, router: router, config: config, + storage: CoreStorageMock(), postStateSubject: .init(.readed(id: "1"))) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -301,6 +304,7 @@ final class ThreadViewModelTests: XCTestCase { let viewModel = ThreadViewModel(interactor: interactor, router: router, config: config, + storage: CoreStorageMock(), postStateSubject: .init(.readed(id: "1"))) Given(interactor, .readBody(threadID: .any, willThrow: NSError())) @@ -328,6 +332,7 @@ final class ThreadViewModelTests: XCTestCase { let viewModel = ThreadViewModel(interactor: interactor, router: router, config: config, + storage: CoreStorageMock(), postStateSubject: .init(.readed(id: "1"))) let post = Post(authorName: "", @@ -368,6 +373,7 @@ final class ThreadViewModelTests: XCTestCase { let viewModel = ThreadViewModel(interactor: interactor, router: router, config: config, + storage: CoreStorageMock(), postStateSubject: .init(.readed(id: "1"))) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -392,6 +398,7 @@ final class ThreadViewModelTests: XCTestCase { let viewModel = ThreadViewModel(interactor: interactor, router: router, config: config, + storage: CoreStorageMock(), postStateSubject: .init(.readed(id: "1"))) Given(interactor, .addCommentTo(threadID: .any, rawBody: .any, parentID: .any, willThrow: NSError()) ) @@ -415,6 +422,7 @@ final class ThreadViewModelTests: XCTestCase { let viewModel = ThreadViewModel(interactor: interactor, router: router, config: config, + storage: CoreStorageMock(), postStateSubject: .init(.readed(id: "1"))) viewModel.totalPages = 2 diff --git a/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModelTests.swift b/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModelTests.swift index f94484fc2..33a98349c 100644 --- a/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModelTests.swift @@ -18,7 +18,8 @@ final class DiscussionSearchTopicsViewModelTests: XCTestCase { let interactor = DiscussionInteractorProtocolMock() let router = DiscussionRouterMock() let viewModel = DiscussionSearchTopicsViewModel(courseID: "123", - interactor: interactor, + interactor: interactor, + storage: CoreStorageMock(), router: router, debounce: .test) @@ -71,6 +72,7 @@ final class DiscussionSearchTopicsViewModelTests: XCTestCase { let router = DiscussionRouterMock() let viewModel = DiscussionSearchTopicsViewModel(courseID: "123", interactor: interactor, + storage: CoreStorageMock(), router: router, debounce: .test) @@ -100,6 +102,7 @@ final class DiscussionSearchTopicsViewModelTests: XCTestCase { let router = DiscussionRouterMock() let viewModel = DiscussionSearchTopicsViewModel(courseID: "123", interactor: interactor, + storage: CoreStorageMock(), router: router, debounce: .test) @@ -127,6 +130,7 @@ final class DiscussionSearchTopicsViewModelTests: XCTestCase { let router = DiscussionRouterMock() let viewModel = DiscussionSearchTopicsViewModel(courseID: "123", interactor: interactor, + storage: CoreStorageMock(), router: router, debounce: .test) diff --git a/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift b/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift index 052930f5b..9b9275d55 100644 --- a/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift @@ -102,14 +102,29 @@ final class PostViewModelTests: XCTestCase { ]) let discussionInfo = DiscussionInfo(discussionID: "1", blackouts: []) + + var interactor: DiscussionInteractorProtocolMock! + var router: DiscussionRouterMock! + var config: ConfigMock! + var viewModel: PostsViewModel! + + override func setUp() async throws { + try await super.setUp() + + interactor = DiscussionInteractorProtocolMock() + router = DiscussionRouterMock() + config = ConfigMock() + viewModel = PostsViewModel( + interactor: interactor, + router: router, + config: config, + storage: CoreStorageMock() + ) + } func testGetThreadListSuccess() async throws { - let interactor = DiscussionInteractorProtocolMock() - let router = DiscussionRouterMock() - let config = ConfigMock() var result = false - let viewModel = PostsViewModel(interactor: interactor, router: router, config: config) viewModel.courseID = "1" viewModel.type = .allPosts @@ -145,11 +160,8 @@ final class PostViewModelTests: XCTestCase { } func testGetThreadListNoInternetError() async throws { - let interactor = DiscussionInteractorProtocolMock() - let router = DiscussionRouterMock() - 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)) @@ -170,11 +182,8 @@ final class PostViewModelTests: XCTestCase { } func testGetThreadListUnknownError() async throws { - let interactor = DiscussionInteractorProtocolMock() - let router = DiscussionRouterMock() - 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())) @@ -193,11 +202,7 @@ final class PostViewModelTests: XCTestCase { } func testSortingAndFilters() async throws { - let interactor = DiscussionInteractorProtocolMock() - let router = DiscussionRouterMock() - let config = ConfigMock() - let viewModel = PostsViewModel(interactor: interactor, router: router, config: config) - + Given(interactor, .getThreadsList(courseID: .any, type: .any, sort: .any, filter: .any, page: .any, willReturn: threads)) Given(interactor, .getCourseDiscussionInfo(courseID: "1", willReturn: discussionInfo)) diff --git a/Discussion/DiscussionTests/Presentation/Responses/ResponsesViewModelTests.swift b/Discussion/DiscussionTests/Presentation/Responses/ResponsesViewModelTests.swift index a7c59806a..2b010617d 100644 --- a/Discussion/DiscussionTests/Presentation/Responses/ResponsesViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/Responses/ResponsesViewModelTests.swift @@ -108,6 +108,7 @@ final class ResponsesViewModelTests: XCTestCase { let viewModel = ResponsesViewModel(interactor: interactor, router: router, config: config, + storage: CoreStorageMock(), threadStateSubject: .init(.postAdded(id: "1"))) Given(interactor, .getCommentResponses(commentID: .any, page: .any, @@ -135,6 +136,7 @@ final class ResponsesViewModelTests: XCTestCase { let viewModel = ResponsesViewModel(interactor: interactor, router: router, config: config, + storage: CoreStorageMock(), threadStateSubject: .init(.postAdded(id: "1"))) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -161,6 +163,7 @@ final class ResponsesViewModelTests: XCTestCase { let viewModel = ResponsesViewModel(interactor: interactor, router: router, config: config, + storage: CoreStorageMock(), threadStateSubject: .init(.postAdded(id: "1"))) Given(interactor, .getCommentResponses(commentID: .any, page: .any, willThrow: NSError())) @@ -184,6 +187,7 @@ final class ResponsesViewModelTests: XCTestCase { let viewModel = ResponsesViewModel(interactor: interactor, router: router, config: config, + storage: CoreStorageMock(), threadStateSubject: .init(.postAdded(id: "1"))) Given(interactor, .addCommentTo(threadID: .any, rawBody: .any, parentID: .any, willReturn: post)) @@ -205,6 +209,7 @@ final class ResponsesViewModelTests: XCTestCase { let viewModel = ResponsesViewModel(interactor: interactor, router: router, config: config, + storage: CoreStorageMock(), threadStateSubject: .init(.postAdded(id: "1"))) let noInternetError = AFError.sessionInvalidated(error: URLError(.notConnectedToInternet)) @@ -228,6 +233,7 @@ final class ResponsesViewModelTests: XCTestCase { let viewModel = ResponsesViewModel(interactor: interactor, router: router, config: config, + storage: CoreStorageMock(), threadStateSubject: .init(.postAdded(id: "1"))) Given(interactor, .addCommentTo(threadID: .any, rawBody: .any, parentID: .any, willThrow: NSError())) @@ -249,6 +255,7 @@ final class ResponsesViewModelTests: XCTestCase { let viewModel = ResponsesViewModel(interactor: interactor, router: router, config: config, + storage: CoreStorageMock(), threadStateSubject: .init(.postAdded(id: "1"))) viewModel.totalPages = 2 diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index f76ef7808..578e94df6 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -142,6 +142,7 @@ class ScreenAssembly: Assembly { connectivity: r.resolve(ConnectivityProtocol.self)!, router: r.resolve(DiscoveryRouter.self)!, analytics: r.resolve(DiscoveryAnalytics.self)!, + storage: r.resolve(CoreStorage.self)!, debounce: .searchDebounce ) } @@ -168,7 +169,8 @@ class ScreenAssembly: Assembly { ListDashboardViewModel( interactor: r.resolve(DashboardInteractorProtocol.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, - analytics: r.resolve(DashboardAnalytics.self)! + analytics: r.resolve(DashboardAnalytics.self)!, + storage: r.resolve(CoreStorage.self)! ) } @@ -177,7 +179,8 @@ class ScreenAssembly: Assembly { interactor: r.resolve(DashboardInteractorProtocol.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, analytics: r.resolve(DashboardAnalytics.self)!, - config: r.resolve(ConfigProtocol.self)! + config: r.resolve(ConfigProtocol.self)!, + storage: r.resolve(CoreStorage.self)! ) } @@ -185,7 +188,8 @@ class ScreenAssembly: Assembly { AllCoursesViewModel( interactor: r.resolve(DashboardInteractorProtocol.self)!, connectivity: r.resolve(ConnectivityProtocol.self)!, - analytics: r.resolve(DashboardAnalytics.self)! + analytics: r.resolve(DashboardAnalytics.self)!, + storage: r.resolve(CoreStorage.self)! ) } @@ -520,6 +524,7 @@ class ScreenAssembly: Assembly { DiscussionSearchTopicsViewModel( courseID: courseID, interactor: r.resolve(DiscussionInteractorProtocol.self)!, + storage: r.resolve(CoreStorage.self)!, router: r.resolve(DiscussionRouter.self)!, debounce: .searchDebounce ) @@ -529,7 +534,8 @@ class ScreenAssembly: Assembly { PostsViewModel( interactor: r.resolve(DiscussionInteractorProtocol.self)!, router: r.resolve(DiscussionRouter.self)!, - config: r.resolve(ConfigProtocol.self)! + config: r.resolve(ConfigProtocol.self)!, + storage: r.resolve(CoreStorage.self)! ) } @@ -538,6 +544,7 @@ class ScreenAssembly: Assembly { interactor: r.resolve(DiscussionInteractorProtocol.self)!, router: r.resolve(DiscussionRouter.self)!, config: r.resolve(ConfigProtocol.self)!, + storage: r.resolve(CoreStorage.self)!, postStateSubject: subject ) } @@ -547,6 +554,7 @@ class ScreenAssembly: Assembly { interactor: r.resolve(DiscussionInteractorProtocol.self)!, router: r.resolve(DiscussionRouter.self)!, config: r.resolve(ConfigProtocol.self)!, + storage: r.resolve(CoreStorage.self)!, threadStateSubject: subject ) } diff --git a/OpenEdX/Data/AppStorage.swift b/OpenEdX/Data/AppStorage.swift index 5f8b8f924..d064da7aa 100644 --- a/OpenEdX/Data/AppStorage.swift +++ b/OpenEdX/Data/AppStorage.swift @@ -88,9 +88,9 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto } } - public var cookiesDate: String? { + public var cookiesDate: Date? { get { - return userDefaults.string(forKey: KEY_COOKIES_DATE) + return userDefaults.object(forKey: KEY_COOKIES_DATE) as? Date } set(newValue) { if let newValue { @@ -123,7 +123,13 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto } set(newValue) { if let newValue { - userDefaults.set(newValue.dateToString(style: .iso8601), forKey: KEY_REVIEW_LAST_REVIEW_DATE) + userDefaults.set( + newValue.dateToString( + style: .iso8601, + useRelativeDates: false + ), + forKey: KEY_REVIEW_LAST_REVIEW_DATE + ) } else { userDefaults.removeObject(forKey: KEY_REVIEW_LAST_REVIEW_DATE) } @@ -285,7 +291,13 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto } set(newValue) { if let newValue { - userDefaults.set(newValue.dateToString(style: .iso8601), forKey: KEY_LAST_CALENDAR_UPDATE_DATE) + userDefaults.set( + newValue.dateToString( + style: .iso8601, + useRelativeDates: useRelativeDates + ), + forKey: KEY_LAST_CALENDAR_UPDATE_DATE + ) } else { userDefaults.removeObject(forKey: KEY_LAST_CALENDAR_UPDATE_DATE) } @@ -318,6 +330,16 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto } } + public var useRelativeDates: Bool { + get { + // We use userDefaults.object to return the default value as true + return userDefaults.object(forKey: KEY_USE_RELATIVE_DATES) as? Bool ?? true + } + set { + userDefaults.set(newValue, forKey: KEY_USE_RELATIVE_DATES) + } + } + public func clear() { accessToken = nil refreshToken = nil @@ -346,4 +368,5 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto private let KEY_HIDE_INACTIVE_COURSES = "hideInactiveCourses" private let KEY_FIRST_CALENDAR_UPDATE = "firstCalendarUpdate" private let KEY_RESET_APP_SUPPORT_DIRECTORY_USER_DATA = "resetAppSupportDirectoryUserData" + private let KEY_USE_RELATIVE_DATES = "useRelativeDates" } diff --git a/Profile/Profile.xcodeproj/project.pbxproj b/Profile/Profile.xcodeproj/project.pbxproj index 0005825de..2401233cd 100644 --- a/Profile/Profile.xcodeproj/project.pbxproj +++ b/Profile/Profile.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ 0288C4EA2BC6AE2D009158B9 /* ManageAccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0288C4E92BC6AE2D009158B9 /* ManageAccountView.swift */; }; 0288C4EC2BC6AE82009158B9 /* ManageAccountViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0288C4EB2BC6AE82009158B9 /* ManageAccountViewModel.swift */; }; 029301DA2938948500E99AB8 /* ProfileType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029301D92938948500E99AB8 /* ProfileType.swift */; }; + 0294987A2C4E4332008FD0E7 /* RelativeDatesToggleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029498792C4E4332008FD0E7 /* RelativeDatesToggleView.swift */; }; 02A4833329B7710A00D33F33 /* DeleteAccountViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833229B7710A00D33F33 /* DeleteAccountViewModelTests.swift */; }; 02A9A91D2978194A00B55797 /* ProfileViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A9A91C2978194A00B55797 /* ProfileViewModelTests.swift */; }; 02A9A91E2978194A00B55797 /* Profile.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 020F834A28DB4CCD0062FA70 /* Profile.framework */; platformFilter = ios; }; @@ -105,6 +106,7 @@ 0288C4E92BC6AE2D009158B9 /* ManageAccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageAccountView.swift; sourceTree = ""; }; 0288C4EB2BC6AE82009158B9 /* ManageAccountViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManageAccountViewModel.swift; sourceTree = ""; }; 029301D92938948500E99AB8 /* ProfileType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileType.swift; sourceTree = ""; }; + 029498792C4E4332008FD0E7 /* RelativeDatesToggleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelativeDatesToggleView.swift; sourceTree = ""; }; 02A4833229B7710A00D33F33 /* DeleteAccountViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = DeleteAccountViewModelTests.swift; path = ProfileTests/Presentation/DeleteAccount/DeleteAccountViewModelTests.swift; sourceTree = SOURCE_ROOT; }; 02A9A91A2978194A00B55797 /* ProfileTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ProfileTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 02A9A91C2978194A00B55797 /* ProfileViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileViewModelTests.swift; sourceTree = ""; }; @@ -340,6 +342,7 @@ 022301E72BF4C4E70028A287 /* ToggleWithDescriptionView.swift */, 02F81DDE2BF4D83E002D3604 /* CalendarDialogView.swift */, 02F81DE22BF502B9002D3604 /* SyncSelector.swift */, + 029498792C4E4332008FD0E7 /* RelativeDatesToggleView.swift */, ); path = Elements; sourceTree = ""; @@ -675,6 +678,7 @@ 021C90D72BC98734004876AF /* DatesAndCalendarViewModel.swift in Sources */, 021D925528DC92F800ACC565 /* ProfileInteractor.swift in Sources */, 029301DA2938948500E99AB8 /* ProfileType.swift in Sources */, + 0294987A2C4E4332008FD0E7 /* RelativeDatesToggleView.swift in Sources */, 0259104429C39C9E004B5A55 /* SettingsView.swift in Sources */, 02B089432A9F832200754BD4 /* ProfileStorage.swift in Sources */, 022213D72C0E18A300B917E6 /* CourseCalendarState.swift in Sources */, diff --git a/Profile/Profile/Data/ProfileRepository.swift b/Profile/Profile/Data/ProfileRepository.swift index 2bd35cdfe..a593f7380 100644 --- a/Profile/Profile/Data/ProfileRepository.swift +++ b/Profile/Profile/Data/ProfileRepository.swift @@ -162,7 +162,7 @@ public class ProfileRepository: ProfileRepositoryProtocol { public func getCourseDates(courseID: String) async throws -> CourseDates { let courseDates = try await api.requestData( ProfileEndpoint.getCourseDates(courseID: courseID) - ).mapResponse(DataLayer.CourseDates.self).domain + ).mapResponse(DataLayer.CourseDates.self).domain(useRelativeDates: storage.useRelativeDates) return courseDates } } diff --git a/Profile/Profile/Data/ProfileStorage.swift b/Profile/Profile/Data/ProfileStorage.swift index 8110ada04..88e8fe48b 100644 --- a/Profile/Profile/Data/ProfileStorage.swift +++ b/Profile/Profile/Data/ProfileStorage.swift @@ -11,6 +11,7 @@ import UIKit public protocol ProfileStorage { var userProfile: DataLayer.UserProfile? {get set} + var useRelativeDates: Bool {get set} var calendarSettings: CalendarSettings? {get set} var hideInactiveCourses: Bool? {get set} var lastLoginUsername: String? {get set} @@ -23,6 +24,7 @@ public protocol ProfileStorage { public class ProfileStorageMock: ProfileStorage { public var userProfile: DataLayer.UserProfile? + public var useRelativeDates: Bool = true public var calendarSettings: CalendarSettings? public var hideInactiveCourses: Bool? public var lastLoginUsername: String? diff --git a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift index 886909605..35ea4a7f7 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarView.swift @@ -44,7 +44,7 @@ public struct DatesAndCalendarView: View { ScrollView { Group { calendarSyncCard -// relativeDatesToggle + RelativeDatesToggleView(useRelativeDates: $viewModel.profileStorage.useRelativeDates) } .padding(.horizontal, isHorizontal ? 48 : 0) } @@ -177,31 +177,6 @@ public struct DatesAndCalendarView: View { .cardStyle(bgColor: Theme.Colors.textInputUnfocusedBackground, strokeColor: .clear) } } - - // MARK: - Options Toggle - private var relativeDatesToggle: some View { - VStack(alignment: .leading) { - Text(ProfileLocalization.Options.title) - .font(Theme.Fonts.labelLarge) - .foregroundColor(Theme.Colors.textPrimary) - HStack(spacing: 16) { - Toggle("", isOn: $viewModel.useRelativeDates) - .frame(width: 50) - .tint(Theme.Colors.accentColor) - Text(ProfileLocalization.Options.useRelativeDates) - .font(Theme.Fonts.bodyLarge) - .foregroundColor(Theme.Colors.textPrimary) - } - Text(ProfileLocalization.Options.showRelativeDates) - .font(Theme.Fonts.labelMedium) - .foregroundColor(Theme.Colors.textPrimary) - } - .padding(.horizontal, 24) - .frame(minWidth: 0, - maxWidth: .infinity, - alignment: .top) - .accessibilityIdentifier("relative_dates_toggle") - } } #if DEBUG diff --git a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift index 560d18978..667546c24 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift @@ -16,7 +16,6 @@ import Core // MARK: - DatesAndCalendarViewModel public class DatesAndCalendarViewModel: ObservableObject { - @Published var useRelativeDates: Bool = false @Published var showCalendaAccessDenied: Bool = false @Published var showDisableCalendarSync: Bool = false @Published var showError: Bool = false @@ -73,7 +72,7 @@ public class DatesAndCalendarViewModel: ObservableObject { var router: ProfileRouter private var interactor: ProfileInteractorProtocol - private var profileStorage: ProfileStorage + @Published var profileStorage: ProfileStorage private var persistence: ProfilePersistenceProtocol private var calendarManager: CalendarManagerProtocol private var connectivity: ConnectivityProtocol @@ -187,8 +186,7 @@ public class DatesAndCalendarViewModel: ObservableObject { colorSelection: colorString, calendarName: calendarName, accountSelection: accountSelection, - courseCalendarSync: self.courseCalendarSync, - useRelativeDates: self.useRelativeDates + courseCalendarSync: self.courseCalendarSync ) profileStorage.lastCalendarName = calendarName } diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Elements/RelativeDatesToggleView.swift b/Profile/Profile/Presentation/DatesAndCalendar/Elements/RelativeDatesToggleView.swift new file mode 100644 index 000000000..967e52245 --- /dev/null +++ b/Profile/Profile/Presentation/DatesAndCalendar/Elements/RelativeDatesToggleView.swift @@ -0,0 +1,42 @@ +// +// RelativeDatesToggleView.swift +// Profile +// +// Created by  Stepanok Ivan on 22.07.2024. +// + +import SwiftUI +import Theme + +struct RelativeDatesToggleView: View { + @Binding var useRelativeDates: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(ProfileLocalization.Options.title) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + HStack(spacing: 16) { + Toggle("", isOn: $useRelativeDates) + .frame(width: 50) + .tint(Theme.Colors.accentColor) + Text(ProfileLocalization.Options.useRelativeDates) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textPrimary) + } + Text( + useRelativeDates + ? ProfileLocalization.Options.showRelativeDates + : ProfileLocalization.Options.showFullDates + ) + .font(Theme.Fonts.labelMedium) + .foregroundColor(Theme.Colors.textPrimary) + } + .padding(.top, 14) + .padding(.horizontal, 24) + .frame(minWidth: 0, + maxWidth: .infinity, + alignment: .leading) + .accessibilityIdentifier("relative_dates_toggle") + } +} diff --git a/Profile/Profile/Presentation/DatesAndCalendar/Models/CalendarSettings.swift b/Profile/Profile/Presentation/DatesAndCalendar/Models/CalendarSettings.swift index 7c1970d0c..b4b63bb4e 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/Models/CalendarSettings.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/Models/CalendarSettings.swift @@ -12,20 +12,17 @@ public struct CalendarSettings: Codable { public var calendarName: String? public var accountSelection: String public var courseCalendarSync: Bool - public var useRelativeDates: Bool public init( colorSelection: String, calendarName: String?, accountSelection: String, - courseCalendarSync: Bool, - useRelativeDates: Bool + courseCalendarSync: Bool ) { self.colorSelection = colorSelection self.calendarName = calendarName self.accountSelection = accountSelection self.courseCalendarSync = courseCalendarSync - self.useRelativeDates = useRelativeDates } enum CodingKeys: String, CodingKey { @@ -33,7 +30,6 @@ public struct CalendarSettings: Codable { case calendarName case accountSelection case courseCalendarSync - case useRelativeDates } public init(from decoder: Decoder) throws { @@ -42,7 +38,6 @@ public struct CalendarSettings: Codable { self.calendarName = try container.decode(String.self, forKey: .calendarName) self.accountSelection = try container.decode(String.self, forKey: .accountSelection) self.courseCalendarSync = try container.decode(Bool.self, forKey: .courseCalendarSync) - self.useRelativeDates = try container.decode(Bool.self, forKey: .useRelativeDates) } public func encode(to encoder: Encoder) throws { @@ -51,6 +46,5 @@ public struct CalendarSettings: Codable { try container.encode(calendarName, forKey: .calendarName) try container.encode(accountSelection, forKey: .accountSelection) try container.encode(courseCalendarSync, forKey: .courseCalendarSync) - try container.encode(useRelativeDates, forKey: .useRelativeDates) } } diff --git a/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift b/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift index 82414e263..154c869e3 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/SyncCalendarOptionsView.swift @@ -95,7 +95,7 @@ public struct SyncCalendarOptionsView: View { coursesToSync .padding(.bottom, 24) } -// relativeDatesToggle + RelativeDatesToggleView(useRelativeDates: $viewModel.profileStorage.useRelativeDates) } .padding(.horizontal, isHorizontal ? 48 : 0) .frameLimit(width: proxy.size.width) @@ -258,21 +258,6 @@ public struct SyncCalendarOptionsView: View { strokeColor: .clear ) } - - @ViewBuilder - private var relativeDatesToggle: some View { - Divider() - .padding(.horizontal, 24) - - optionTitle(ProfileLocalization.Options.title) - .padding(.vertical, 16) - ToggleWithDescriptionView( - text: ProfileLocalization.Options.useRelativeDates, - description: ProfileLocalization.Options.showRelativeDates, - toggle: $viewModel.reconnectRequired - ) - .padding(.horizontal, 24) - } } #if DEBUG diff --git a/Profile/Profile/SwiftGen/Strings.swift b/Profile/Profile/SwiftGen/Strings.swift index 0bcf37eec..0963ce3b5 100644 --- a/Profile/Profile/SwiftGen/Strings.swift +++ b/Profile/Profile/SwiftGen/Strings.swift @@ -240,6 +240,8 @@ public enum ProfileLocalization { public static let title = ProfileLocalization.tr("Localizable", "LOGOUT_ALERT.TITLE", fallback: "Confirm log out") } public enum Options { + /// Show full dates like “January 1, 2021” + public static let showFullDates = ProfileLocalization.tr("Localizable", "OPTIONS.SHOW_FULL_DATES", fallback: "Show full dates like “January 1, 2021”") /// Show relative dates like “Tomorrow” and “Yesterday” public static let showRelativeDates = ProfileLocalization.tr("Localizable", "OPTIONS.SHOW_RELATIVE_DATES", fallback: "Show relative dates like “Tomorrow” and “Yesterday”") /// Options diff --git a/Profile/Profile/en.lproj/Localizable.strings b/Profile/Profile/en.lproj/Localizable.strings index 2a680ddb2..42a2871fb 100644 --- a/Profile/Profile/en.lproj/Localizable.strings +++ b/Profile/Profile/en.lproj/Localizable.strings @@ -112,6 +112,7 @@ "OPTIONS.TITLE" = "Options"; "OPTIONS.USE_RELATIVE_DATES" = "Use relative dates"; "OPTIONS.SHOW_RELATIVE_DATES" = "Show relative dates like “Tomorrow” and “Yesterday”"; +"OPTIONS.SHOW_FULL_DATES" = "Show full dates like “January 1, 2021”"; "DATES_AND_CALENDAR.TITLE" = "Dates & Calendar"; "COURSE_CALENDAR_SYNC.TITLE" = "Course Calendar Sync"; diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 534a1dde5..843268a30 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -2230,9 +2230,9 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { } open func profileTrackEvent(_ 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) + addInvocation(.m_profileTrackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_profileTrackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void + perform?(`event`, `biValue`) } open func profileScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { @@ -2258,7 +2258,7 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { case m_profileWifiToggle__action_action(Parameter) case m_profileUserDeleteAccountClicked case m_profileDeleteAccountSuccess__success_success(Parameter) - case m_profileEvent__eventbiValue_biValue(Parameter, Parameter) + case m_profileTrackEvent__eventbiValue_biValue(Parameter, Parameter) case m_profileScreenEvent__eventbiValue_biValue(Parameter, Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -2305,7 +2305,7 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { 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)): + case (.m_profileTrackEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_profileTrackEvent__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")) @@ -2337,7 +2337,7 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { 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 + case let .m_profileTrackEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue case let .m_profileScreenEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue } } @@ -2358,7 +2358,7 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { 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:)" + case .m_profileTrackEvent__eventbiValue_biValue: return ".profileTrackEvent(_:biValue:)" case .m_profileScreenEvent__eventbiValue_biValue: return ".profileScreenEvent(_:biValue:)" } } @@ -2393,7 +2393,7 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { 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 static func profileTrackEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_profileTrackEvent__eventbiValue_biValue(`event`, `biValue`))} public static func profileScreenEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_profileScreenEvent__eventbiValue_biValue(`event`, `biValue`))} } @@ -2446,8 +2446,8 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { 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 static func profileTrackEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_profileTrackEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) } public static func profileScreenEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { return Perform(method: .m_profileScreenEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) From 23c219c7c705823d60350c794232836d947c18e7 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Mon, 30 Sep 2024 11:19:03 +0300 Subject: [PATCH 34/55] feat: [FC-0047] xBlock offline mode (#474) * feat: course offline mode * Merge branch 'develop' into feat/course-offline-mode * fix: address feedback * fix: address feedback * fix: address feedback * fix: resolve merge conflicts * fix: update mocks * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback * fix: address feedback --- .../AuthorizationMock.generated.swift | 840 ++++++++++++++++ Core/Core.xcodeproj/project.pbxproj | 45 + Core/Core/Analytics/CoreAnalytics.swift | 2 + .../check_circle.imageset/Contents.json | 15 + .../check_circle.imageset/check_circle.svg | 3 + .../download.imageset/Contents.json | 15 + .../download.imageset/download.svg | 12 + .../remove.imageset/Contents.json | 15 + .../remove.imageset/remove.svg | 3 + .../report_octagon.imageset/Contents.json | 15 + .../report_octagon.imageset/report.svg | 3 + .../visibility.imageset/Contents.json | 15 + .../visibility.imageset/visibility.svg | 3 + .../CoreDataModel.xcdatamodel/contents | 12 +- .../Persistence/CorePersistenceProtocol.swift | 33 + .../Repository/OfflineSyncRepository.swift | 42 + Core/Core/Domain/Model/CourseBlockModel.swift | 48 +- Core/Core/Domain/Model/OfflineProgress.swift | 50 + Core/Core/Domain/OfflineSyncInteractor.swift | 29 + Core/Core/Extensions/IntExtension.swift | 25 + Core/Core/Extensions/Notification.swift | 3 + Core/Core/Network/DownloadManager.swift | 292 ++++-- Core/Core/Network/OfflineSyncEndpoint.swift | 64 ++ Core/Core/Network/OfflineSyncManager.swift | 85 ++ Core/Core/SwiftGen/Assets.swift | 5 + Core/Core/View/Base/FileWebView.swift | 65 ++ Core/Core/View/Base/WebBrowser.swift | 16 +- Core/Core/View/Base/WebUnitView.swift | 62 +- Core/Core/View/Base/WebUnitViewModel.swift | 8 +- Core/Core/View/Base/Webview/WebView.swift | 58 +- Course/Course.xcodeproj/project.pbxproj | 57 +- Course/Course/Data/CourseRepository.swift | 33 +- .../Model/Data_CourseOutlineResponse.swift | 24 +- .../Course/Data/Network/CourseEndpoint.swift | 4 +- .../CourseCoreModel.xcdatamodel/contents | 7 +- Course/Course/Domain/CourseInteractor.swift | 25 +- .../Container/CourseContainerView.swift | 13 + .../Container/CourseContainerViewModel.swift | 633 ++++++++++-- .../Course/Presentation/CourseAnalytics.swift | 2 + .../Presentation/Offline/OfflineView.swift | 268 ++++++ .../Subviews/LargestDownloadsView.swift | 178 ++++ .../TotalDownloadedProgressView.swift | 105 ++ .../Outline/ContinueWithView.swift | 6 +- .../Outline/CourseOutlineView.swift | 4 +- .../CourseVerticalImageView.swift | 15 +- .../CourseVertical/CourseVerticalView.swift | 42 - .../CourseVerticalViewModel.swift | 26 +- .../DeviceStorageFullAlertView.swift | 235 +++++ .../ActionViews/DownloadActionView.swift | 326 +++++++ .../ActionViews/DownloadErrorAlertView.swift | 298 ++++++ .../CourseVideoDownloadBarViewModel.swift | 12 +- .../Subviews/CustomDisclosureGroup.swift | 62 +- .../Presentation/Unit/CourseUnitView.swift | 46 +- .../Unit/CourseUnitViewModel.swift | 60 +- .../DropdownList/CourseUnitDropDownCell.swift | 3 +- .../DropdownList/CourseUnitDropDownList.swift | 12 +- .../CourseUnitVerticalsDropdownView.swift | 12 +- .../Unit/Subviews/OfflineContentView.swift | 65 ++ .../Presentation/Unit/Subviews/WebView.swift | 9 +- Course/Course/SwiftGen/Strings.swift | 112 +++ Course/Course/en.lproj/Localizable.strings | 54 ++ Course/CourseTests/CourseMock.generated.swift | 901 ++++++++++++++++++ .../CourseContainerViewModelTests.swift | 87 +- .../Unit/CourseUnitViewModelTests.swift | 12 +- .../DashboardCoreModel.xcdatamodel/contents | 10 +- .../DashboardMock.generated.swift | 840 ++++++++++++++++ .../WebDiscovery/DiscoveryWebview.swift | 4 +- .../WebPrograms/ProgramWebviewView.swift | 8 +- .../DiscoveryMock.generated.swift | 840 ++++++++++++++++ .../DiscussionMock.generated.swift | 840 ++++++++++++++++ OpenEdX.xcodeproj/project.pbxproj | 12 + OpenEdX/AppDelegate.swift | 46 + OpenEdX/DI/ScreenAssembly.swift | 36 +- OpenEdX/Data/CorePersistence.swift | 192 +++- OpenEdX/Data/CoursePersistence.swift | 26 +- OpenEdX/Info.plist | 14 +- .../AnalyticsManager/AnalyticsManager.swift | 9 + OpenEdX/Router.swift | 3 +- OpenEdX/View/MainScreenView.swift | 7 + OpenEdX/View/MainScreenViewModel.swift | 43 +- .../Subviews/ProfileSupportInfoView.swift | 3 +- .../Presentation/Settings/SettingsView.swift | 4 +- .../Settings/SettingsViewModel.swift | 9 +- .../Settings/VideoQualityView.swift | 4 +- .../Settings/VideoSettingsView.swift | 4 +- .../Settings/SettingsViewModelTests.swift | 24 +- .../ProfileTests/ProfileMock.generated.swift | 840 ++++++++++++++++ .../Colors/Background.colorset/Contents.json | 6 +- .../Contents.json | 6 +- .../Contents.json | 6 +- .../disabledButton.colorset/Contents.json | 38 + .../disabledButtonText.colorset/Contents.json | 38 + .../Contents.json | 6 +- .../Colors/shade.colorset/Contents.json | 38 + Theme/Theme/SwiftGen/ThemeAssets.swift | 3 + Theme/Theme/Theme.swift | 3 + 96 files changed, 9193 insertions(+), 400 deletions(-) create mode 100644 Core/Core/Assets.xcassets/check_circle.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/check_circle.imageset/check_circle.svg create mode 100644 Core/Core/Assets.xcassets/download.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/download.imageset/download.svg create mode 100644 Core/Core/Assets.xcassets/remove.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/remove.imageset/remove.svg create mode 100644 Core/Core/Assets.xcassets/report_octagon.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/report_octagon.imageset/report.svg create mode 100644 Core/Core/Assets.xcassets/visibility.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/visibility.imageset/visibility.svg create mode 100644 Core/Core/Data/Repository/OfflineSyncRepository.swift create mode 100644 Core/Core/Domain/Model/OfflineProgress.swift create mode 100644 Core/Core/Domain/OfflineSyncInteractor.swift create mode 100644 Core/Core/Extensions/IntExtension.swift create mode 100644 Core/Core/Network/OfflineSyncEndpoint.swift create mode 100644 Core/Core/Network/OfflineSyncManager.swift create mode 100644 Core/Core/View/Base/FileWebView.swift create mode 100644 Course/Course/Presentation/Offline/OfflineView.swift create mode 100644 Course/Course/Presentation/Offline/Subviews/LargestDownloadsView.swift create mode 100644 Course/Course/Presentation/Offline/Subviews/TotalDownloadedProgressView.swift create mode 100644 Course/Course/Presentation/Subviews/ActionViews/DeviceStorageFullAlertView.swift create mode 100644 Course/Course/Presentation/Subviews/ActionViews/DownloadActionView.swift create mode 100644 Course/Course/Presentation/Subviews/ActionViews/DownloadErrorAlertView.swift create mode 100644 Course/Course/Presentation/Unit/Subviews/OfflineContentView.swift create mode 100644 Theme/Theme/Assets.xcassets/Colors/StyledButton/disabledButton.colorset/Contents.json create mode 100644 Theme/Theme/Assets.xcassets/Colors/StyledButton/disabledButtonText.colorset/Contents.json create mode 100644 Theme/Theme/Assets.xcassets/Colors/shade.colorset/Contents.json diff --git a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift index 34ce42889..a6520c298 100644 --- a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift +++ b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift @@ -2278,6 +2278,611 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { } } +// MARK: - CorePersistenceProtocol + +open class CorePersistenceProtocolMock: CorePersistenceProtocol, 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 set(userId: Int) { + addInvocation(.m_set__userId_userId(Parameter.value(`userId`))) + let perform = methodPerformValue(.m_set__userId_userId(Parameter.value(`userId`))) as? (Int) -> Void + perform?(`userId`) + } + + open func getUserID() -> Int? { + addInvocation(.m_getUserID) + let perform = methodPerformValue(.m_getUserID) as? () -> Void + perform?() + var __value: Int? = nil + do { + __value = try methodReturnValue(.m_getUserID).casted() + } catch { + // do nothing + } + return __value + } + + open func publisher() -> AnyPublisher { + addInvocation(.m_publisher) + let perform = methodPerformValue(.m_publisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_publisher).casted() + } catch { + onFatalFailure("Stub return value not specified for publisher(). Use given") + Failure("Stub return value not specified for publisher(). Use given") + } + return __value + } + + open func addToDownloadQueue(tasks: [DownloadDataTask]) { + addInvocation(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) as? ([DownloadDataTask]) -> Void + perform?(`tasks`) + } + + open func saveOfflineProgress(progress: OfflineProgress) { + addInvocation(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) + let perform = methodPerformValue(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) as? (OfflineProgress) -> Void + perform?(`progress`) + } + + open func loadProgress(for blockID: String) -> OfflineProgress? { + addInvocation(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + var __value: OfflineProgress? = nil + do { + __value = try methodReturnValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))).casted() + } catch { + // do nothing + } + return __value + } + + open func loadAllOfflineProgress() -> [OfflineProgress] { + addInvocation(.m_loadAllOfflineProgress) + let perform = methodPerformValue(.m_loadAllOfflineProgress) as? () -> Void + perform?() + var __value: [OfflineProgress] + do { + __value = try methodReturnValue(.m_loadAllOfflineProgress).casted() + } catch { + onFatalFailure("Stub return value not specified for loadAllOfflineProgress(). Use given") + Failure("Stub return value not specified for loadAllOfflineProgress(). Use given") + } + return __value + } + + open func deleteProgress(for blockID: String) { + addInvocation(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + } + + open func deleteAllProgress() { + addInvocation(.m_deleteAllProgress) + let perform = methodPerformValue(.m_deleteAllProgress) as? () -> Void + perform?() + } + + open func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) { + addInvocation(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) as? ([CourseBlock], DownloadQuality) -> Void + perform?(`blocks`, `downloadQuality`) + } + + open func nextBlockForDownloading() -> DownloadDataTask? { + addInvocation(.m_nextBlockForDownloading) + let perform = methodPerformValue(.m_nextBlockForDownloading) as? () -> Void + perform?() + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_nextBlockForDownloading).casted() + } catch { + // do nothing + } + return __value + } + + open func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) { + addInvocation(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) + let perform = methodPerformValue(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) as? (String, DownloadState, Data?) -> Void + perform?(`id`, `state`, `resumeData`) + } + + open func deleteDownloadDataTask(id: String) throws { + addInvocation(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) + let perform = methodPerformValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) as? (String) -> Void + perform?(`id`) + do { + _ = try methodReturnValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func saveDownloadDataTask(_ task: DownloadDataTask) { + addInvocation(.m_saveDownloadDataTask__task(Parameter.value(`task`))) + let perform = methodPerformValue(.m_saveDownloadDataTask__task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void + perform?(`task`) + } + + open func downloadDataTask(for blockId: String) -> DownloadDataTask? { + addInvocation(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void + perform?(`blockId`) + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))).casted() + } catch { + // do nothing + } + return __value + } + + open func getDownloadDataTasks() -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasks) + let perform = methodPerformValue(.m_getDownloadDataTasks) as? () -> Void + perform?() + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasks).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasks(). Use given") + Failure("Stub return value not specified for getDownloadDataTasks(). Use given") + } + return __value + } + + open func getDownloadDataTasksForCourse(_ courseId: String) -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + } + return __value + } + + + fileprivate enum MethodType { + case m_set__userId_userId(Parameter) + case m_getUserID + case m_publisher + case m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>) + case m_saveOfflineProgress__progress_progress(Parameter) + case m_loadProgress__for_blockID(Parameter) + case m_loadAllOfflineProgress + case m_deleteProgress__for_blockID(Parameter) + case m_deleteAllProgress + case m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>, Parameter) + case m_nextBlockForDownloading + case m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter, Parameter, Parameter) + case m_deleteDownloadDataTask__id_id(Parameter) + case m_saveDownloadDataTask__task(Parameter) + case m_downloadDataTask__for_blockId(Parameter) + case m_getDownloadDataTasks + case m_getDownloadDataTasksForCourse__courseId(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_set__userId_userId(let lhsUserid), .m_set__userId_userId(let rhsUserid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUserid, rhs: rhsUserid, with: matcher), lhsUserid, rhsUserid, "userId")) + return Matcher.ComparisonResult(results) + + case (.m_getUserID, .m_getUserID): return .match + + case (.m_publisher, .m_publisher): return .match + + case (.m_addToDownloadQueue__tasks_tasks(let lhsTasks), .m_addToDownloadQueue__tasks_tasks(let rhsTasks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTasks, rhs: rhsTasks, with: matcher), lhsTasks, rhsTasks, "tasks")) + return Matcher.ComparisonResult(results) + + case (.m_saveOfflineProgress__progress_progress(let lhsProgress), .m_saveOfflineProgress__progress_progress(let rhsProgress)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsProgress, rhs: rhsProgress, with: matcher), lhsProgress, rhsProgress, "progress")) + return Matcher.ComparisonResult(results) + + case (.m_loadProgress__for_blockID(let lhsBlockid), .m_loadProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_loadAllOfflineProgress, .m_loadAllOfflineProgress): return .match + + case (.m_deleteProgress__for_blockID(let lhsBlockid), .m_deleteProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_deleteAllProgress, .m_deleteAllProgress): return .match + + case (.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let lhsBlocks, let lhsDownloadquality), .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let rhsBlocks, let rhsDownloadquality)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDownloadquality, rhs: rhsDownloadquality, with: matcher), lhsDownloadquality, rhsDownloadquality, "downloadQuality")) + return Matcher.ComparisonResult(results) + + case (.m_nextBlockForDownloading, .m_nextBlockForDownloading): return .match + + case (.m_updateDownloadState__id_idstate_stateresumeData_resumeData(let lhsId, let lhsState, let lhsResumedata), .m_updateDownloadState__id_idstate_stateresumeData_resumeData(let rhsId, let rhsState, let rhsResumedata)): + 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: lhsState, rhs: rhsState, with: matcher), lhsState, rhsState, "state")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsResumedata, rhs: rhsResumedata, with: matcher), lhsResumedata, rhsResumedata, "resumeData")) + return Matcher.ComparisonResult(results) + + case (.m_deleteDownloadDataTask__id_id(let lhsId), .m_deleteDownloadDataTask__id_id(let rhsId)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "id")) + return Matcher.ComparisonResult(results) + + case (.m_saveDownloadDataTask__task(let lhsTask), .m_saveDownloadDataTask__task(let rhsTask)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "_ task")) + return Matcher.ComparisonResult(results) + + case (.m_downloadDataTask__for_blockId(let lhsBlockid), .m_downloadDataTask__for_blockId(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + return Matcher.ComparisonResult(results) + + case (.m_getDownloadDataTasks, .m_getDownloadDataTasks): return .match + + case (.m_getDownloadDataTasksForCourse__courseId(let lhsCourseid), .m_getDownloadDataTasksForCourse__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 + } + } + + func intValue() -> Int { + switch self { + case let .m_set__userId_userId(p0): return p0.intValue + case .m_getUserID: return 0 + case .m_publisher: return 0 + case let .m_addToDownloadQueue__tasks_tasks(p0): return p0.intValue + case let .m_saveOfflineProgress__progress_progress(p0): return p0.intValue + case let .m_loadProgress__for_blockID(p0): return p0.intValue + case .m_loadAllOfflineProgress: return 0 + case let .m_deleteProgress__for_blockID(p0): return p0.intValue + case .m_deleteAllProgress: return 0 + case let .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(p0, p1): return p0.intValue + p1.intValue + case .m_nextBlockForDownloading: return 0 + case let .m_updateDownloadState__id_idstate_stateresumeData_resumeData(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_deleteDownloadDataTask__id_id(p0): return p0.intValue + case let .m_saveDownloadDataTask__task(p0): return p0.intValue + case let .m_downloadDataTask__for_blockId(p0): return p0.intValue + case .m_getDownloadDataTasks: return 0 + case let .m_getDownloadDataTasksForCourse__courseId(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_set__userId_userId: return ".set(userId:)" + case .m_getUserID: return ".getUserID()" + case .m_publisher: return ".publisher()" + case .m_addToDownloadQueue__tasks_tasks: return ".addToDownloadQueue(tasks:)" + case .m_saveOfflineProgress__progress_progress: return ".saveOfflineProgress(progress:)" + case .m_loadProgress__for_blockID: return ".loadProgress(for:)" + case .m_loadAllOfflineProgress: return ".loadAllOfflineProgress()" + case .m_deleteProgress__for_blockID: return ".deleteProgress(for:)" + case .m_deleteAllProgress: return ".deleteAllProgress()" + case .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality: return ".addToDownloadQueue(blocks:downloadQuality:)" + case .m_nextBlockForDownloading: return ".nextBlockForDownloading()" + case .m_updateDownloadState__id_idstate_stateresumeData_resumeData: return ".updateDownloadState(id:state:resumeData:)" + case .m_deleteDownloadDataTask__id_id: return ".deleteDownloadDataTask(id:)" + case .m_saveDownloadDataTask__task: return ".saveDownloadDataTask(_:)" + case .m_downloadDataTask__for_blockId: return ".downloadDataTask(for:)" + case .m_getDownloadDataTasks: return ".getDownloadDataTasks()" + case .m_getDownloadDataTasksForCourse__courseId: return ".getDownloadDataTasksForCourse(_:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func getUserID(willReturn: Int?...) -> MethodStub { + return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func publisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadProgress(for blockID: Parameter, willReturn: OfflineProgress?...) -> MethodStub { + return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadAllOfflineProgress(willReturn: [OfflineProgress]...) -> MethodStub { + return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func nextBlockForDownloading(willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func downloadDataTask(for blockId: Parameter, willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasks(willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getUserID(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Int?] = [] + let given: Given = { return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Int?).self) + willProduce(stubber) + return given + } + public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func loadProgress(for blockID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [OfflineProgress?] = [] + let given: Given = { return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (OfflineProgress?).self) + willProduce(stubber) + return given + } + public static func loadAllOfflineProgress(willProduce: (Stubber<[OfflineProgress]>) -> Void) -> MethodStub { + let willReturn: [[OfflineProgress]] = [] + let given: Given = { return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([OfflineProgress]).self) + willProduce(stubber) + return given + } + public static func nextBlockForDownloading(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func downloadDataTask(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func deleteDownloadDataTask(id: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func deleteDownloadDataTask(id: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func set(userId: Parameter) -> Verify { return Verify(method: .m_set__userId_userId(`userId`))} + public static func getUserID() -> Verify { return Verify(method: .m_getUserID)} + public static func publisher() -> Verify { return Verify(method: .m_publisher)} + public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>) -> Verify { return Verify(method: .m_addToDownloadQueue__tasks_tasks(`tasks`))} + public static func saveOfflineProgress(progress: Parameter) -> Verify { return Verify(method: .m_saveOfflineProgress__progress_progress(`progress`))} + public static func loadProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_loadProgress__for_blockID(`blockID`))} + public static func loadAllOfflineProgress() -> Verify { return Verify(method: .m_loadAllOfflineProgress)} + public static func deleteProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_deleteProgress__for_blockID(`blockID`))} + public static func deleteAllProgress() -> Verify { return Verify(method: .m_deleteAllProgress)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`))} + public static func nextBlockForDownloading() -> Verify { return Verify(method: .m_nextBlockForDownloading)} + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter) -> Verify { return Verify(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`))} + public static func deleteDownloadDataTask(id: Parameter) -> Verify { return Verify(method: .m_deleteDownloadDataTask__id_id(`id`))} + public static func saveDownloadDataTask(_ task: Parameter) -> Verify { return Verify(method: .m_saveDownloadDataTask__task(`task`))} + public static func downloadDataTask(for blockId: Parameter) -> Verify { return Verify(method: .m_downloadDataTask__for_blockId(`blockId`))} + public static func getDownloadDataTasks() -> Verify { return Verify(method: .m_getDownloadDataTasks)} + public static func getDownloadDataTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func set(userId: Parameter, perform: @escaping (Int) -> Void) -> Perform { + return Perform(method: .m_set__userId_userId(`userId`), performs: perform) + } + public static func getUserID(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getUserID, performs: perform) + } + public static func publisher(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_publisher, performs: perform) + } + public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>, perform: @escaping ([DownloadDataTask]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__tasks_tasks(`tasks`), performs: perform) + } + public static func saveOfflineProgress(progress: Parameter, perform: @escaping (OfflineProgress) -> Void) -> Perform { + return Perform(method: .m_saveOfflineProgress__progress_progress(`progress`), performs: perform) + } + public static func loadProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_loadProgress__for_blockID(`blockID`), performs: perform) + } + public static func loadAllOfflineProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_loadAllOfflineProgress, performs: perform) + } + public static func deleteProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteProgress__for_blockID(`blockID`), performs: perform) + } + public static func deleteAllProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllProgress, performs: perform) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter, perform: @escaping ([CourseBlock], DownloadQuality) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`), performs: perform) + } + public static func nextBlockForDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_nextBlockForDownloading, performs: perform) + } + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter, perform: @escaping (String, DownloadState, Data?) -> Void) -> Perform { + return Perform(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`), performs: perform) + } + public static func deleteDownloadDataTask(id: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteDownloadDataTask__id_id(`id`), performs: perform) + } + public static func saveDownloadDataTask(_ task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { + return Perform(method: .m_saveDownloadDataTask__task(`task`), performs: perform) + } + public static func downloadDataTask(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_downloadDataTask__for_blockId(`blockId`), performs: perform) + } + public static func getDownloadDataTasks(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasks, performs: perform) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), 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 { @@ -2473,6 +3078,20 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] { + addInvocation(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) + let perform = methodPerformValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) as? ([CourseSequential]) -> Void + perform?(`sequentials`) + var __value: [CourseSequential] + do { + __value = try methodReturnValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))).casted() + } catch { + onFatalFailure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + Failure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + } + return __value + } + open func resumeDownloading() throws { addInvocation(.m_resumeDownloading) let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void @@ -2520,6 +3139,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) + case m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case m_removeAppSupportDirectoryUnusedContent @@ -2573,6 +3193,11 @@ 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_updateUnzippedFileSize__for_sequentials(let lhsSequentials), .m_updateUnzippedFileSize__for_sequentials(let rhsSequentials)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSequentials, rhs: rhsSequentials, with: matcher), lhsSequentials, rhsSequentials, "for sequentials")) + return Matcher.ComparisonResult(results) + case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): @@ -2600,6 +3225,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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_updateUnzippedFileSize__for_sequentials(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .m_removeAppSupportDirectoryUnusedContent: return 0 @@ -2620,6 +3246,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + case .m_updateUnzippedFileSize__for_sequentials: return ".updateUnzippedFileSize(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" @@ -2655,6 +3282,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willReturn: [CourseSequential]...) -> MethodStub { + return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willReturn: Bool...) -> MethodStub { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -2693,6 +3323,13 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willProduce: (Stubber<[CourseSequential]>) -> Void) -> MethodStub { + let willReturn: [[CourseSequential]] = [] + let given: Given = { return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseSequential]).self) + willProduce(stubber) + return given + } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [Bool] = [] let given: Given = { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -2777,6 +3414,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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 updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>) -> Verify { return Verify(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`))} 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 func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} @@ -2823,6 +3461,9 @@ 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 updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, perform: @escaping ([CourseSequential]) -> Void) -> Perform { + return Perform(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), performs: perform) + } public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_resumeDownloading, performs: perform) } @@ -2907,6 +3548,205 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } +// MARK: - OfflineSyncInteractorProtocol + +open class OfflineSyncInteractorProtocolMock: OfflineSyncInteractorProtocol, 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 submitOfflineProgress(courseID: String, blockID: String, data: String) throws -> Bool { + addInvocation(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) + let perform = methodPerformValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) as? (String, String, String) -> Void + perform?(`courseID`, `blockID`, `data`) + var __value: Bool + do { + __value = try methodReturnValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + Failure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + } catch { + throw error + } + return __value + } + + + fileprivate enum MethodType { + case m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter, Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let lhsCourseid, let lhsBlockid, let lhsData), .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let rhsCourseid, let rhsBlockid, let rhsData)): + 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: lhsData, rhs: rhsData, with: matcher), lhsData, rhsData, "data")) + return Matcher.ComparisonResult(results) + } + } + + func intValue() -> Int { + switch self { + case let .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + } + } + func assertionName() -> String { + switch self { + case .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data: return ".submitOfflineProgress(courseID:blockID:data:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willReturn: Bool...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Bool).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter) -> Verify { return Verify(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { + return Perform(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), 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 { diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 5e0328b90..d68bf217b 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 021D924828DC860C00ACC565 /* Data_UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D924728DC860C00ACC565 /* Data_UserProfile.swift */; }; 021D925028DC89D100ACC565 /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D924F28DC89D100ACC565 /* UserProfile.swift */; }; 021D925728DCF12900ACC565 /* AlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D925628DCF12900ACC565 /* AlertView.swift */; }; + 02228B312C2232D2009A5F28 /* IntExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02228B302C2232D2009A5F28 /* IntExtension.swift */; }; 022020462C11BB2200D15795 /* Data_CourseDates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022020452C11BB2200D15795 /* Data_CourseDates.swift */; }; 02280F5B294B4E6F0032823A /* Connectivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02280F5A294B4E6F0032823A /* Connectivity.swift */; }; 02284C182A3B1AE00007117F /* UIApplicationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02284C172A3B1AE00007117F /* UIApplicationExtension.swift */; }; @@ -44,6 +45,7 @@ 025B36752A13B7D5001A640E /* UnitButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025B36742A13B7D5001A640E /* UnitButtonView.swift */; }; 025EF2F62971740000B838AB /* YouTubePlayerKit in Frameworks */ = {isa = PBXBuildFile; productRef = 025EF2F52971740000B838AB /* YouTubePlayerKit */; }; 0260E58028FD792800BBBE18 /* WebUnitViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0260E57F28FD792800BBBE18 /* WebUnitViewModel.swift */; }; + 0267F8512C3C256F0089D810 /* FileWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0267F8502C3C256F0089D810 /* FileWebView.swift */; }; 027BD3922907D88F00392132 /* Data_RegistrationFields.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3912907D88F00392132 /* Data_RegistrationFields.swift */; }; 027BD39C2908810C00392132 /* RegisterUser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD39B2908810C00392132 /* RegisterUser.swift */; }; 027BD3A82909474200392132 /* KeyboardAvoidingViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3A62909474100392132 /* KeyboardAvoidingViewController.swift */; }; @@ -70,12 +72,18 @@ 02935B732BCECAD000B22F66 /* Data_PrimaryEnrollment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02935B722BCECAD000B22F66 /* Data_PrimaryEnrollment.swift */; }; 02935B752BCEE6D600B22F66 /* PrimaryEnrollment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02935B742BCEE6D600B22F66 /* PrimaryEnrollment.swift */; }; 0295C885299B99DD00ABE571 /* RefreshableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0295C884299B99DD00ABE571 /* RefreshableScrollView.swift */; }; + 029A13262C2457D9005FB830 /* OfflineProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029A13252C2457D9005FB830 /* OfflineProgress.swift */; }; + 029A13282C246AE6005FB830 /* OfflineSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029A13272C246AE6005FB830 /* OfflineSyncManager.swift */; }; + 029A132A2C2471DF005FB830 /* OfflineSyncRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029A13292C2471DF005FB830 /* OfflineSyncRepository.swift */; }; + 029A132C2C2471F8005FB830 /* OfflineSyncEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029A132B2C2471F8005FB830 /* OfflineSyncEndpoint.swift */; }; + 029A13302C2479E7005FB830 /* OfflineSyncInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029A132F2C2479E7005FB830 /* OfflineSyncInteractor.swift */; }; 029EE3ED2BF6650500F64F33 /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029EE3EC2BF6650500F64F33 /* Bundle.swift */; }; 02A463112AEA966C00331037 /* AppReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A463102AEA966C00331037 /* AppReviewView.swift */; }; 02A4833529B8A73400D33F33 /* CorePersistenceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833429B8A73400D33F33 /* CorePersistenceProtocol.swift */; }; 02A4833829B8A8F900D33F33 /* CoreDataModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833629B8A8F800D33F33 /* CoreDataModel.xcdatamodeld */; }; 02A4833A29B8A9AB00D33F33 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833929B8A9AB00D33F33 /* DownloadManager.swift */; }; 02A4833C29B8C57800D33F33 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833B29B8C57800D33F33 /* DownloadView.swift */; }; + 02AA27942C2C1B88006F5B6A /* ZipArchive in Frameworks */ = {isa = PBXBuildFile; productRef = 02AA27932C2C1B88006F5B6A /* ZipArchive */; }; 02A8C5812C05DBB4004B91FF /* Data_EnrollmentsStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A8C5802C05DBB4004B91FF /* Data_EnrollmentsStatus.swift */; }; 02AFCC182AEFDB24000360F0 /* ThirdPartyMailClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AFCC172AEFDB24000360F0 /* ThirdPartyMailClient.swift */; }; 02AFCC1A2AEFDC18000360F0 /* ThirdPartyMailer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AFCC192AEFDC18000360F0 /* ThirdPartyMailer.swift */; }; @@ -211,6 +219,7 @@ 021D924728DC860C00ACC565 /* Data_UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_UserProfile.swift; sourceTree = ""; }; 021D924F28DC89D100ACC565 /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; 021D925628DCF12900ACC565 /* AlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertView.swift; sourceTree = ""; }; + 02228B302C2232D2009A5F28 /* IntExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntExtension.swift; sourceTree = ""; }; 022020452C11BB2200D15795 /* Data_CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_CourseDates.swift; sourceTree = ""; }; 02280F5A294B4E6F0032823A /* Connectivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.swift; sourceTree = ""; }; 02284C172A3B1AE00007117F /* UIApplicationExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationExtension.swift; sourceTree = ""; }; @@ -240,6 +249,7 @@ 025A204F2C071EB0003EA08D /* ErrorAlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorAlertView.swift; sourceTree = ""; }; 025B36742A13B7D5001A640E /* UnitButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitButtonView.swift; sourceTree = ""; }; 0260E57F28FD792800BBBE18 /* WebUnitViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebUnitViewModel.swift; sourceTree = ""; }; + 0267F8502C3C256F0089D810 /* FileWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileWebView.swift; sourceTree = ""; }; 027BD3912907D88F00392132 /* Data_RegistrationFields.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_RegistrationFields.swift; sourceTree = ""; }; 027BD39B2908810C00392132 /* RegisterUser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterUser.swift; sourceTree = ""; }; 027BD3A62909474100392132 /* KeyboardAvoidingViewController.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyboardAvoidingViewController.swift; sourceTree = ""; }; @@ -266,6 +276,11 @@ 02935B722BCECAD000B22F66 /* Data_PrimaryEnrollment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_PrimaryEnrollment.swift; sourceTree = ""; }; 02935B742BCEE6D600B22F66 /* PrimaryEnrollment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryEnrollment.swift; sourceTree = ""; }; 0295C884299B99DD00ABE571 /* RefreshableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableScrollView.swift; sourceTree = ""; }; + 029A13252C2457D9005FB830 /* OfflineProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineProgress.swift; sourceTree = ""; }; + 029A13272C246AE6005FB830 /* OfflineSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineSyncManager.swift; sourceTree = ""; }; + 029A13292C2471DF005FB830 /* OfflineSyncRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineSyncRepository.swift; sourceTree = ""; }; + 029A132B2C2471F8005FB830 /* OfflineSyncEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineSyncEndpoint.swift; sourceTree = ""; }; + 029A132F2C2479E7005FB830 /* OfflineSyncInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineSyncInteractor.swift; sourceTree = ""; }; 029EE3EC2BF6650500F64F33 /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = ""; }; 02A463102AEA966C00331037 /* AppReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReviewView.swift; sourceTree = ""; }; 02A4833429B8A73400D33F33 /* CorePersistenceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorePersistenceProtocol.swift; sourceTree = ""; }; @@ -413,6 +428,7 @@ C8C446EF233F81B9FABB77D2 /* Pods_App_Core.framework in Frameworks */, 142EDD6C2B831D1400F9F320 /* BranchSDK in Frameworks */, BA8FA66C2AD59BBC00EA029A /* GoogleSignIn in Frameworks */, + 02AA27942C2C1B88006F5B6A /* ZipArchive in Frameworks */, E055A5392B18DC95008D9E5E /* Theme.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -434,6 +450,7 @@ isa = PBXGroup; children = ( 0236961828F9A26900EEF206 /* AuthRepository.swift */, + 029A13292C2471DF005FB830 /* OfflineSyncRepository.swift */, ); path = Repository; sourceTree = ""; @@ -498,6 +515,7 @@ 0283347F28D4DCD200C828FC /* ViewExtension.swift */, 02F6EF4928D9F0A700835477 /* DateExtension.swift */, 02F98A7E28F81EE900DE94C0 /* Container+App.swift */, + 02228B302C2232D2009A5F28 /* IntExtension.swift */, 027BD3BB2909478B00392132 /* UIResponder+CurrentResponder.swift */, 027BD3BA2909478B00392132 /* UIView+EnclosingScrollView.swift */, 02E225AF291D29EB0067769A /* UrlExtension.swift */, @@ -633,6 +651,7 @@ children = ( 0727878728D3172D002E9142 /* Model */, 0236961A28F9A28B00EEF206 /* AuthInteractor.swift */, + 029A132F2C2479E7005FB830 /* OfflineSyncInteractor.swift */, ); path = Domain; sourceTree = ""; @@ -652,6 +671,7 @@ 020C31C8290AC3F700D6DEA2 /* PickerFields.swift */, 02EBC75A2C19DE3D00BE182C /* CourseForSync.swift */, 076F297E2A1F80C800967E7D /* Pagination.swift */, + 029A13252C2457D9005FB830 /* OfflineProgress.swift */, 02286D152C106393005EEC8D /* CourseDates.swift */, 02EBC7562C19DCDB00BE182C /* SyncStatus.swift */, ); @@ -710,8 +730,10 @@ 0727877A28D24A1D002E9142 /* HeadersRedirectHandler.swift */, 0727877E28D25B24002E9142 /* Alamofire+Error.swift */, 0236961E28F9A2F600EEF206 /* AuthEndpoint.swift */, + 029A132B2C2471F8005FB830 /* OfflineSyncEndpoint.swift */, 0255D55729362839004DBC1A /* UploadBodyEncoding.swift */, 02A4833929B8A9AB00D33F33 /* DownloadManager.swift */, + 029A13272C246AE6005FB830 /* OfflineSyncManager.swift */, ); path = Network; sourceTree = ""; @@ -778,6 +800,7 @@ 020D72F32BB76DFE00773319 /* VisualEffectView.swift */, 06DEA4A22BBD66A700110D20 /* BackNavigationButton.swift */, 06DEA4A42BBD66D700110D20 /* BackNavigationButtonViewModel.swift */, + 0267F8502C3C256F0089D810 /* FileWebView.swift */, ); path = Base; sourceTree = ""; @@ -972,6 +995,7 @@ BA8FA66B2AD59BBC00EA029A /* GoogleSignIn */, BAF0D4CA2AD6AE14007AC334 /* FacebookLogin */, 142EDD6B2B831D1400F9F320 /* BranchSDK */, + 02AA27932C2C1B88006F5B6A /* ZipArchive */, ); productName = Core; productReference = 0770DE0828D07831006D8A5D /* Core.framework */; @@ -1012,6 +1036,7 @@ BA8FA65E2AD574D700EA029A /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */, BA8FA6712AD6ABA300EA029A /* XCRemoteSwiftPackageReference "facebook-ios-sdk" */, 142EDD6A2B831D1400F9F320 /* XCRemoteSwiftPackageReference "ios-branch-sdk-spm" */, + 02AA27922C2C1B61006F5B6A /* XCRemoteSwiftPackageReference "ZipArchive" */, ); productRefGroup = 0770DE0928D07831006D8A5D /* Products */; projectDirPath = ""; @@ -1112,6 +1137,7 @@ 027F1BF72C071C820001A24C /* NavigationTitle.swift in Sources */, 06619EAA2B8F2936001FAADE /* ReadabilityModifier.swift in Sources */, BAFB99902B14B377007D09F9 /* GoogleConfig.swift in Sources */, + 029A132C2C2471F8005FB830 /* OfflineSyncEndpoint.swift in Sources */, 02E225B0291D29EB0067769A /* UrlExtension.swift in Sources */, 02CF46C829546AA200A698EE /* NoCachedDataError.swift in Sources */, 0727877728D23847002E9142 /* DataLayer.swift in Sources */, @@ -1125,6 +1151,7 @@ 064987972B4D69FF0071642A /* WebView.swift in Sources */, 0770DE2A28D0929E006D8A5D /* HTTPTask.swift in Sources */, 06619EAD2B90918B001FAADE /* ReadabilityInjection.swift in Sources */, + 029A13262C2457D9005FB830 /* OfflineProgress.swift in Sources */, 02284C182A3B1AE00007117F /* UIApplicationExtension.swift in Sources */, 0255D5582936283A004DBC1A /* UploadBodyEncoding.swift in Sources */, 06619EAF2B973B25001FAADE /* AccessibilityInjection.swift in Sources */, @@ -1187,6 +1214,7 @@ 021D925728DCF12900ACC565 /* AlertView.swift in Sources */, 027BD3A82909474200392132 /* KeyboardAvoidingViewController.swift in Sources */, 025A20502C071EB0003EA08D /* ErrorAlertView.swift in Sources */, + 029A13282C246AE6005FB830 /* OfflineSyncManager.swift in Sources */, 02E93F852AEBAEBC006C4750 /* AppReviewViewModel.swift in Sources */, A595689B2B6173DF00ED4F90 /* BranchConfig.swift in Sources */, 0770DE2528D08FBA006D8A5D /* CoreStorage.swift in Sources */, @@ -1209,6 +1237,7 @@ 0259104A29C4A5B6004B5A55 /* UserSettings.swift in Sources */, 021D925028DC89D100ACC565 /* UserProfile.swift in Sources */, 071009D028D1E3A600344290 /* Constants.swift in Sources */, + 02228B312C2232D2009A5F28 /* IntExtension.swift in Sources */, 0770DE1928D0847D006D8A5D /* BaseRouter.swift in Sources */, BA30427F2B20B320009B64B7 /* SocialAuthError.swift in Sources */, 02286D162C106393005EEC8D /* CourseDates.swift in Sources */, @@ -1218,6 +1247,7 @@ DBF6F2412B014ADA0098414B /* FirebaseConfig.swift in Sources */, 072787B628D37A0E002E9142 /* Validator.swift in Sources */, 0236961D28F9A2D200EEF206 /* Data_AuthResponse.swift in Sources */, + 0267F8512C3C256F0089D810 /* FileWebView.swift in Sources */, A5F4E7B52B61544A00ACD166 /* BrazeConfig.swift in Sources */, 06DEA4A52BBD66D700110D20 /* BackNavigationButtonViewModel.swift in Sources */, 02AFCC182AEFDB24000360F0 /* ThirdPartyMailClient.swift in Sources */, @@ -1256,12 +1286,14 @@ 064987982B4D69FF0071642A /* CSSInjectionProtocol.swift in Sources */, 029EE3ED2BF6650500F64F33 /* Bundle.swift in Sources */, BADB3F5B2AD6EC56004D5CFA /* ResultExtension.swift in Sources */, + 029A132A2C2471DF005FB830 /* OfflineSyncRepository.swift in Sources */, 0236961928F9A26900EEF206 /* AuthRepository.swift in Sources */, 023A1136291432B200D0D354 /* RegistrationTextField.swift in Sources */, 02A463112AEA966C00331037 /* AppReviewView.swift in Sources */, 025B36752A13B7D5001A640E /* UnitButtonView.swift in Sources */, 028F9F39293A452B00DE65D0 /* ResetPassword.swift in Sources */, 0233D56F2AF13EB200BAC8BD /* StarRatingView.swift in Sources */, + 029A13302C2479E7005FB830 /* OfflineSyncInteractor.swift in Sources */, 0604C9AA2B22FACF00AD5DBF /* UIComponentsConfig.swift in Sources */, 027BD3B82909476200392132 /* DismissKeyboardTapViewModifier.swift in Sources */, 024BE3DF29B2615500BCDEE2 /* CGColorExtension.swift in Sources */, @@ -2294,6 +2326,14 @@ minimumVersion = 1.8.0; }; }; + 02AA27922C2C1B61006F5B6A /* XCRemoteSwiftPackageReference "ZipArchive" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/ZipArchive/ZipArchive.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.5.5; + }; + }; 142EDD6A2B831D1400F9F320 /* XCRemoteSwiftPackageReference "ios-branch-sdk-spm" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/BranchMetrics/ios-branch-sdk-spm"; @@ -2326,6 +2366,11 @@ package = 025EF2F42971740000B838AB /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */; productName = YouTubePlayerKit; }; + 02AA27932C2C1B88006F5B6A /* ZipArchive */ = { + isa = XCSwiftPackageProductDependency; + package = 02AA27922C2C1B61006F5B6A /* XCRemoteSwiftPackageReference "ZipArchive" */; + productName = ZipArchive; + }; 142EDD6B2B831D1400F9F320 /* BranchSDK */ = { isa = XCSwiftPackageProductDependency; package = 142EDD6A2B831D1400F9F320 /* XCRemoteSwiftPackageReference "ios-branch-sdk-spm" */; diff --git a/Core/Core/Analytics/CoreAnalytics.swift b/Core/Core/Analytics/CoreAnalytics.swift index 137bf094f..006b41c7d 100644 --- a/Core/Core/Analytics/CoreAnalytics.swift +++ b/Core/Core/Analytics/CoreAnalytics.swift @@ -112,6 +112,7 @@ public enum AnalyticsEvent: String { case finishVerticalBackToOutlineClicked = "Course:Unit Finish Back To Outline Clicked" case courseOutlineCourseTabClicked = "Course:Home Tab" case courseOutlineVideosTabClicked = "Course:Videos Tab" + case courseOutlineOfflineTabClicked = "Course:Offline Tab" case courseOutlineDatesTabClicked = "Course:Dates Tab" case courseOutlineDiscussionTabClicked = "Course:Discussion Tab" case courseOutlineHandoutsTabClicked = "Course:Handouts Tab" @@ -189,6 +190,7 @@ public enum EventBIValue: String { case bulkDeleteVideosSubsection = "edx.bi.app.video.delete.subsection" case dashboardCourseClicked = "edx.bi.app.course.dashboard" case courseOutlineVideosTabClicked = "edx.bi.app.course.video_tab" + case courseOutlineOfflineTabClicked = "edx.bi.app.course.offline_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" diff --git a/Core/Core/Assets.xcassets/check_circle.imageset/Contents.json b/Core/Core/Assets.xcassets/check_circle.imageset/Contents.json new file mode 100644 index 000000000..5a764afa0 --- /dev/null +++ b/Core/Core/Assets.xcassets/check_circle.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "check_circle.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Core/Core/Assets.xcassets/check_circle.imageset/check_circle.svg b/Core/Core/Assets.xcassets/check_circle.imageset/check_circle.svg new file mode 100644 index 000000000..d95b7f0fe --- /dev/null +++ b/Core/Core/Assets.xcassets/check_circle.imageset/check_circle.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/download.imageset/Contents.json b/Core/Core/Assets.xcassets/download.imageset/Contents.json new file mode 100644 index 000000000..b9e339ad9 --- /dev/null +++ b/Core/Core/Assets.xcassets/download.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "download.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Core/Core/Assets.xcassets/download.imageset/download.svg b/Core/Core/Assets.xcassets/download.imageset/download.svg new file mode 100644 index 000000000..94fb61149 --- /dev/null +++ b/Core/Core/Assets.xcassets/download.imageset/download.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/Core/Core/Assets.xcassets/remove.imageset/Contents.json b/Core/Core/Assets.xcassets/remove.imageset/Contents.json new file mode 100644 index 000000000..d72a4557b --- /dev/null +++ b/Core/Core/Assets.xcassets/remove.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "remove.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Core/Core/Assets.xcassets/remove.imageset/remove.svg b/Core/Core/Assets.xcassets/remove.imageset/remove.svg new file mode 100644 index 000000000..a896b5a5a --- /dev/null +++ b/Core/Core/Assets.xcassets/remove.imageset/remove.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/report_octagon.imageset/Contents.json b/Core/Core/Assets.xcassets/report_octagon.imageset/Contents.json new file mode 100644 index 000000000..c24699e07 --- /dev/null +++ b/Core/Core/Assets.xcassets/report_octagon.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "report.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Core/Core/Assets.xcassets/report_octagon.imageset/report.svg b/Core/Core/Assets.xcassets/report_octagon.imageset/report.svg new file mode 100644 index 000000000..4eeadf69b --- /dev/null +++ b/Core/Core/Assets.xcassets/report_octagon.imageset/report.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/visibility.imageset/Contents.json b/Core/Core/Assets.xcassets/visibility.imageset/Contents.json new file mode 100644 index 000000000..af9f4586d --- /dev/null +++ b/Core/Core/Assets.xcassets/visibility.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "visibility.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "template-rendering-intent" : "template" + } +} diff --git a/Core/Core/Assets.xcassets/visibility.imageset/visibility.svg b/Core/Core/Assets.xcassets/visibility.imageset/visibility.svg new file mode 100644 index 000000000..2710fa5d1 --- /dev/null +++ b/Core/Core/Assets.xcassets/visibility.imageset/visibility.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents b/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents index 2fd252f0d..acc66b8db 100644 --- a/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents +++ b/Core/Core/Data/Persistence/CoreDataModel.xcdatamodeld/CoreDataModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -7,6 +7,7 @@ + @@ -19,4 +20,13 @@ + + + + + + + + + \ No newline at end of file diff --git a/Core/Core/Data/Persistence/CorePersistenceProtocol.swift b/Core/Core/Data/Persistence/CorePersistenceProtocol.swift index 49d69b1c3..b17c62a1f 100644 --- a/Core/Core/Data/Persistence/CorePersistenceProtocol.swift +++ b/Core/Core/Data/Persistence/CorePersistenceProtocol.swift @@ -8,10 +8,18 @@ import CoreData import Combine +//sourcery: AutoMockable public protocol CorePersistenceProtocol { func set(userId: Int) func getUserID() -> Int? func publisher() -> AnyPublisher + func addToDownloadQueue(tasks: [DownloadDataTask]) + func saveOfflineProgress(progress: OfflineProgress) + func loadProgress(for blockID: String) -> OfflineProgress? + func loadAllOfflineProgress() -> [OfflineProgress] + func deleteProgress(for blockID: String) + func deleteAllProgress() + func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) async func nextBlockForDownloading() async -> DownloadDataTask? func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) @@ -22,6 +30,31 @@ public protocol CorePersistenceProtocol { func getDownloadDataTasksForCourse(_ courseId: String) async -> [DownloadDataTask] } +#if DEBUG +public class CorePersistenceMock: CorePersistenceProtocol { + + public init() {} + + public func set(userId: Int) {} + public func getUserID() -> Int? {1} + public func publisher() -> AnyPublisher { Just(0).eraseToAnyPublisher() } + public func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) {} + public func addToDownloadQueue(tasks: [DownloadDataTask]) {} + public func nextBlockForDownloading() -> DownloadDataTask? { nil } + public func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) {} + public func deleteDownloadDataTask(id: String) throws {} + public func downloadDataTask(for blockId: String) -> DownloadDataTask? { nil } + public func saveOfflineProgress(progress: OfflineProgress) {} + public func loadProgress(for blockID: String) -> OfflineProgress? { nil } + public func loadAllOfflineProgress() -> [OfflineProgress] { [] } + public func deleteProgress(for blockID: String) {} + public func deleteAllProgress() {} + public func saveDownloadDataTask(_ task: DownloadDataTask) {} + public func getDownloadDataTasks() async -> [DownloadDataTask] {[]} + public func getDownloadDataTasksForCourse(_ courseId: String) async -> [DownloadDataTask] {[]} +} +#endif + public final class CoreBundle { private init() {} } diff --git a/Core/Core/Data/Repository/OfflineSyncRepository.swift b/Core/Core/Data/Repository/OfflineSyncRepository.swift new file mode 100644 index 000000000..386daf7ab --- /dev/null +++ b/Core/Core/Data/Repository/OfflineSyncRepository.swift @@ -0,0 +1,42 @@ +// +// OfflineSyncRepository.swift +// Core +// +// Created by  Stepanok Ivan on 20.06.2024. +// + +import Foundation + +public protocol OfflineSyncRepositoryProtocol { + func submitOfflineProgress(courseID: String, blockID: String, data: String) async throws -> Bool +} + +public class OfflineSyncRepository: OfflineSyncRepositoryProtocol { + + private let api: API + + public init(api: API) { + self.api = api + } + + public func submitOfflineProgress(courseID: String, blockID: String, data: String) async throws -> Bool { + let request = try await api.request( + OfflineSyncEndpoint.submitOfflineProgress( + courseID: courseID, + blockID: blockID, + data: data + ) + ) + + return request.statusCode == 200 + } +} + +// Mark - For testing and SwiftUI preview +#if DEBUG +class OfflineSyncRepositoryMock: OfflineSyncRepositoryProtocol { + public func submitOfflineProgress(courseID: String, blockID: String, data: String) async throws -> Bool { + true + } +} +#endif diff --git a/Core/Core/Domain/Model/CourseBlockModel.swift b/Core/Core/Domain/Model/CourseBlockModel.swift index 96ef3ccde..c2525627b 100644 --- a/Core/Core/Domain/Model/CourseBlockModel.swift +++ b/Core/Core/Domain/Model/CourseBlockModel.swift @@ -129,6 +129,10 @@ public struct CourseSequential: Identifiable { return childs.first(where: { $0.isDownloadable }) != nil } + public var totalSize: Int { + childs.flatMap { $0.childs.filter({ $0.isDownloadable }) }.reduce(0) { $0 + ($1.fileSize ?? 0) } + } + public init( blockId: String, id: String, @@ -233,9 +237,31 @@ public struct CourseBlock: Hashable, Identifiable { public let subtitles: [SubtitleUrl]? public let encodedVideo: CourseBlockEncodedVideo? public let multiDevice: Bool? + public var offlineDownload: OfflineDownload? + public var actualFileSize: Int? public var isDownloadable: Bool { - encodedVideo?.isDownloadable ?? false + encodedVideo?.isDownloadable ?? false || offlineDownload?.isDownloadable ?? false + } + + public var fileSize: Int? { + if let actualFileSize { + return actualFileSize + } else if let fileSize = encodedVideo?.desktopMP4?.fileSize { + return fileSize + } else if let fileSize = encodedVideo?.fallback?.fileSize { + return fileSize + } else if let fileSize = encodedVideo?.hls?.fileSize { + return fileSize + } else if let fileSize = encodedVideo?.mobileHigh?.fileSize { + return fileSize + } else if let fileSize = encodedVideo?.mobileLow?.fileSize { + return fileSize + } else if let fileSize = offlineDownload?.fileSize { + return fileSize + } else { + return nil + } } public init( @@ -252,7 +278,8 @@ public struct CourseBlock: Hashable, Identifiable { webUrl: String, subtitles: [SubtitleUrl]? = nil, encodedVideo: CourseBlockEncodedVideo?, - multiDevice: Bool? + multiDevice: Bool?, + offlineDownload: OfflineDownload? ) { self.blockId = blockId self.id = id @@ -268,6 +295,23 @@ public struct CourseBlock: Hashable, Identifiable { self.subtitles = subtitles self.encodedVideo = encodedVideo self.multiDevice = multiDevice + self.offlineDownload = offlineDownload + } +} + +public struct OfflineDownload { + public let fileUrl: String + public var lastModified: String + public let fileSize: Int + + public init(fileUrl: String, lastModified: String, fileSize: Int) { + self.fileUrl = fileUrl + self.lastModified = lastModified + self.fileSize = fileSize + } + + public var isDownloadable: Bool { + [".zip"].contains(where: { fileUrl.contains($0) == true }) } } diff --git a/Core/Core/Domain/Model/OfflineProgress.swift b/Core/Core/Domain/Model/OfflineProgress.swift new file mode 100644 index 000000000..efb1a982c --- /dev/null +++ b/Core/Core/Domain/Model/OfflineProgress.swift @@ -0,0 +1,50 @@ +// +// OfflineProgress.swift +// Core +// +// Created by  Stepanok Ivan on 20.06.2024. +// + +import Foundation + +public struct OfflineProgress { + public let blockID: String + public let data: String + public let courseID: String + public let progressJson: String + + public init(progressJson: String) { + self.progressJson = progressJson + if let jsonData = progressJson.data(using: .utf8) { + if let jsonObject = try? JSONSerialization.jsonObject(with: jsonData, options: []) as? [String: Any] { + if let url = jsonObject["url"] as? String, + let data = jsonObject["data"] as? String { + self.blockID = extractBlockID(from: url) + self.data = data + self.courseID = extractCourseID(from: url) + return + } + } + } + // Default values if parsing fails + self.blockID = "" + self.data = "" + self.courseID = "" + + func extractBlockID(from url: String) -> String { + if let range = url.range(of: "xblock/")?.upperBound, + let endRange = url.range(of: "/handler", range: range.. String { + if let range = url.range(of: "courses/")?.upperBound, + let endRange = url.range(of: "/xblock", range: range.. Bool +} + +public class OfflineSyncInteractor: OfflineSyncInteractorProtocol { + private let repository: OfflineSyncRepositoryProtocol + + public init(repository: OfflineSyncRepositoryProtocol) { + self.repository = repository + } + + public func submitOfflineProgress(courseID: String, blockID: String, data: String) async throws -> Bool { + return try await repository.submitOfflineProgress( + courseID: courseID, + blockID: blockID, + data: data + ) + } +} diff --git a/Core/Core/Extensions/IntExtension.swift b/Core/Core/Extensions/IntExtension.swift new file mode 100644 index 000000000..46fe6ea0e --- /dev/null +++ b/Core/Core/Extensions/IntExtension.swift @@ -0,0 +1,25 @@ +// +// IntExtension.swift +// Core +// +// Created by  Stepanok Ivan on 19.06.2024. +// + +import Foundation + +public extension Int { + func formattedFileSize() -> String { + if self == 0 { + return "0MB" + } + let sizeInMB = Double(self) / 1_048_576 + let sizeInGB = Double(self) / 1_073_741_824 + let formattedString: String + if sizeInGB >= 1 { + formattedString = String(format: "%.1fGB", sizeInGB).replacingOccurrences(of: ".0", with: "") + } else { + formattedString = String(format: "%.1fMB", sizeInMB).replacingOccurrences(of: ".0", with: "") + } + return formattedString + } +} diff --git a/Core/Core/Extensions/Notification.swift b/Core/Core/Extensions/Notification.swift index 1a4aeb1db..f70c71e3d 100644 --- a/Core/Core/Extensions/Notification.swift +++ b/Core/Core/Extensions/Notification.swift @@ -21,6 +21,8 @@ public extension Notification.Name { static let shiftCourseDates = Notification.Name("shiftCourseDates") static let profileUpdated = Notification.Name("profileUpdated") static let getCourseDates = Notification.Name("getCourseDates") + static let showDownloadFailed = Notification.Name("showDownloadFailed") + static let tryDownloadAgain = Notification.Name("tryDownloadAgain") static let refreshEnrollments = Notification.Name("refreshEnrollments") } @@ -29,3 +31,4 @@ public extension Notification { case isForced } } + diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index bc3701695..dd58deb4f 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -8,6 +8,7 @@ import Alamofire import SwiftUI import Combine +import ZipArchive public enum DownloadState: String { case waiting @@ -17,17 +18,18 @@ public enum DownloadState: String { public var order: Int { switch self { case .inProgress: - 1 + return 1 case .waiting: - 2 + return 2 case .finished: - 3 + return 3 } } } public enum DownloadType: String { case video + case html, problem } public struct DownloadDataTask: Identifiable, Hashable { @@ -43,6 +45,7 @@ public struct DownloadDataTask: Identifiable, Hashable { public var state: DownloadState public let type: DownloadType public let fileSize: Int + public var lastModified: String? public var fileSizeInMb: Double { Double(fileSize) / 1024.0 / 1024.0 @@ -64,7 +67,8 @@ public struct DownloadDataTask: Identifiable, Hashable { resumeData: Data?, state: DownloadState, type: DownloadType, - fileSize: Int + fileSize: Int, + lastModified: String ) { self.id = id self.courseId = courseId @@ -78,6 +82,7 @@ public struct DownloadDataTask: Identifiable, Hashable { self.state = state self.type = type self.fileSize = fileSize + self.lastModified = lastModified } public init(sourse: CDDownloadData) { @@ -93,6 +98,7 @@ public struct DownloadDataTask: Identifiable, Hashable { self.state = DownloadState(rawValue: sourse.state ?? "") ?? .waiting self.type = DownloadType(rawValue: sourse.type ?? "") ?? .video self.fileSize = Int(sourse.fileSize) + self.lastModified = sourse.lastModified } } @@ -120,10 +126,11 @@ public protocol DownloadManagerProtocol { func deleteAllFiles() async func fileUrl(for blockId: String) -> URL? + func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] func resumeDownloading() async throws func isLargeVideosSize(blocks: [CourseBlock]) -> Bool - + func removeAppSupportDirectoryUnusedContent() } @@ -152,6 +159,9 @@ public class DownloadManager: DownloadManagerProtocol { private var currentDownloadEventPublisher: PassthroughSubject = .init() private let backgroundTaskProvider = BackgroundTaskProvider() private var cancellables = Set() + private var failedDownloads: [DownloadDataTask] = [] + + private let indexPage = "index.html" private var downloadQuality: DownloadQuality { appStorage.userSettings?.downloadQuality ?? .auto @@ -174,6 +184,20 @@ public class DownloadManager: DownloadManagerProtocol { Task { try? await self.resumeDownloading() } + + NotificationCenter.default.publisher(for: .tryDownloadAgain) + .compactMap { $0.object as? [DownloadDataTask] } + .sink { [weak self] downloads in + self?.tryDownloadAgain(downloads: downloads) + } + .store(in: &cancellables) + } + + private func tryDownloadAgain(downloads: [DownloadDataTask]) { + persistence.addToDownloadQueue(tasks: downloads) + Task { + try? await newDownload() + } } // MARK: - Publishers @@ -279,10 +303,65 @@ public class DownloadManager: DownloadManagerProtocol { } } + public func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] { + var updatedSequentials = sequentials + + for i in 0.. Int { + let fileManager = FileManager.default + let resourceKeys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey] + var totalSize: Int64 = 0 + + if let enumerator = fileManager.enumerator( + at: url, + includingPropertiesForKeys: resourceKeys, + options: [], + errorHandler: nil + ) { + for case let fileUrl as URL in enumerator { + let resourceValues = try fileUrl.resourceValues(forKeys: Set(resourceKeys)) + if resourceValues.isDirectory == false { + if let fileSize = resourceValues.fileSize { + totalSize += Int64(fileSize) + } + } + } + } + + return Int(totalSize) + } + public func deleteAllFiles() async { let downloadsData = await getDownloadTasks() for downloadData in downloadsData { - if let fileURL = await fileUrl(for: downloadData.id) { + if let fileURL = fileUrl(for: downloadData.id) { do { try FileManager.default.removeItem(at: fileURL) } catch { @@ -292,17 +371,23 @@ public class DownloadManager: DownloadManagerProtocol { } currentDownloadEventPublisher.send(.clearedAll) } - + public func fileUrl(for blockId: String) -> URL? { guard let data = persistence.downloadDataTask(for: blockId), data.url.count > 0, - data.state == .finished - else { - return nil + data.state == .finished else { return nil } + let path = filesFolderUrl + switch data.type { + case .html, .problem: + if let folderUrl = URL(string: data.url) { + let folder = folderUrl.deletingPathExtension().lastPathComponent + return path?.appendingPathComponent(folder).appendingPathComponent(indexPage) + } else { + return nil + } + case .video: + return path?.appendingPathComponent(data.fileName) } - let path = videosFolderUrl - let fileName = data.fileName - return path?.appendingPathComponent(fileName) } // MARK: - Private Intents @@ -313,10 +398,29 @@ public class DownloadManager: DownloadManagerProtocol { } guard let downloadTask = await persistence.nextBlockForDownloading() else { isDownloadingInProgress = false + if !failedDownloads.isEmpty { + DispatchQueue.main.async { + NotificationCenter.default.post( + name: .showDownloadFailed, + object: self.failedDownloads + ) + self.failedDownloads = [] + } + } + return + } + if !connectivity.isInternetAvaliable { + failedDownloads.append(downloadTask) + try await cancelDownloading(task: downloadTask) return } + currentDownloadTask = downloadTask - try downloadFileWithProgress(downloadTask) + if downloadTask.type == .html || downloadTask.type == .problem { + try downloadHTMLWithProgress(downloadTask) + } else { + try downloadFileWithProgress(downloadTask) + } currentDownloadEventPublisher.send(.started(downloadTask)) } @@ -349,7 +453,7 @@ public class DownloadManager: DownloadManagerProtocol { downloadRequest = AF.download(url) } - downloadRequest?.downloadProgress { [weak self] prog in + downloadRequest?.downloadProgress { [weak self] prog in guard let self else { return } let fractionCompleted = prog.fractionCompleted self.currentDownloadTask?.progress = fractionCompleted @@ -361,7 +465,16 @@ public class DownloadManager: DownloadManagerProtocol { downloadRequest?.responseData { [weak self] data in guard let self else { return } - if let data = data.value, let url = self.videosFolderUrl { + if let error = data.error { + if error.asAFError?.isExplicitlyCancelledError == false { + failedDownloads.append(download) + Task { + try? await self.newDownload() + } + return + } + } + if let data = data.value, let url = self.filesFolderUrl { self.saveFile(fileName: download.fileName, data: data, folderURL: url) self.persistence.updateDownloadState( id: download.id, @@ -377,6 +490,62 @@ public class DownloadManager: DownloadManagerProtocol { } } + private func downloadHTMLWithProgress(_ download: DownloadDataTask) throws { + guard let url = URL(string: download.url) else { + return + } + + persistence.updateDownloadState( + id: download.id, + state: .inProgress, + resumeData: download.resumeData + ) + self.isDownloadingInProgress = true + if let resumeData = download.resumeData { + downloadRequest = AF.download(resumingWith: resumeData) + } else { + downloadRequest = AF.download(url) + } + + downloadRequest?.downloadProgress { [weak self] prog in + guard let self else { return } + let fractionCompleted = prog.fractionCompleted + self.currentDownloadTask?.progress = fractionCompleted + self.currentDownloadTask?.state = .inProgress + self.currentDownloadEventPublisher.send(.progress(fractionCompleted, download)) + let completed = Double(fractionCompleted * 100) + debugLog(">>>>> Downloading", download.url, completed, "%") + } + + downloadRequest?.responseData { [weak self] data in + guard let self else { return } + if let error = data.error { + if error.asAFError?.isExplicitlyCancelledError == false { + failedDownloads.append(download) + Task { + try? await self.newDownload() + } + return + } + } + if let data = data.value, let url = self.filesFolderUrl, + let fileName = URL(string: download.url)?.lastPathComponent { + self.saveFile(fileName: fileName, data: data, folderURL: url) + self.unzipFile(url: url.appendingPathComponent(fileName)) + self.persistence.updateDownloadState( + id: download.id, + state: .finished, + resumeData: nil + ) + self.currentDownloadTask?.state = .finished + self.currentDownloadEventPublisher.send(.finished(download)) + Task { + try? await self.newDownload() + } + } + } + } + private func waitingAll() async { let tasks = await persistence.getDownloadDataTasks() for task in tasks.filter({ $0.state == .inProgress }) { @@ -417,8 +586,9 @@ public class DownloadManager: DownloadManagerProtocol { .store(in: &cancellables) } - lazy var videosFolderUrl: URL? = { + var filesFolderUrl: URL? { let documentDirectoryURL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0] + guard let folderPathComponent else { return nil } let directoryURL = documentDirectoryURL.appendingPathComponent(folderPathComponent, isDirectory: true) if FileManager.default.fileExists(atPath: directoryURL.path) { @@ -436,13 +606,13 @@ public class DownloadManager: DownloadManagerProtocol { return nil } } - }() + } - private var folderPathComponent: String { + private var folderPathComponent: String? { if let id = appStorage.user?.id { return "\(id)_Files" } - return "Files" + return nil } private func saveFile(fileName: String, data: Data, folderURL: URL) { @@ -453,11 +623,38 @@ public class DownloadManager: DownloadManagerProtocol { debugLog("SaveFile Error", error.localizedDescription) } } - + + private func unzipFile(url: URL) { + let fileName = url.deletingPathExtension().lastPathComponent + guard let directoryURL = filesFolderUrl else { + return + } + let uniqueDirectory = directoryURL.appendingPathComponent(fileName, isDirectory: true) + + try? FileManager.default.removeItem(at: uniqueDirectory) + + do { + try FileManager.default.createDirectory( + at: uniqueDirectory, + withIntermediateDirectories: true, + attributes: nil + ) + } catch { + debugLog("Error creating temporary directory: \(error.localizedDescription)") + } + SSZipArchive.unzipFile(atPath: url.path, toDestination: uniqueDirectory.path) + + do { + try FileManager.default.removeItem(at: url) + } catch { + debugLog("Error removing file: \(error.localizedDescription)") + } + } + public func removeAppSupportDirectoryUnusedContent() { deleteMD5HashedFolders() } - + private func getApplicationSupportDirectory() -> URL? { let fileManager = FileManager.default do { @@ -473,18 +670,18 @@ public class DownloadManager: DownloadManagerProtocol { return nil } } - + private func isMD5Hash(_ folderName: String) -> Bool { let md5Regex = "^[a-fA-F0-9]{32}$" let predicate = NSPredicate(format: "SELF MATCHES %@", md5Regex) return predicate.evaluate(with: folderName) } - + private func deleteMD5HashedFolders() { guard let appSupportDirectory = getApplicationSupportDirectory() else { return } - + let fileManager = FileManager.default do { let folderContents = try fileManager.contentsOfDirectory( @@ -592,9 +789,9 @@ public final class BackgroundTaskProvider { #if DEBUG public class DownloadManagerMock: DownloadManagerProtocol { - public init() { - - } + public init() {} + + public func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] {[]} public var currentDownloadTask: DownloadDataTask? { return nil @@ -619,15 +816,14 @@ public class DownloadManagerMock: DownloadManagerProtocol { resumeData: nil, state: .inProgress, type: .video, - fileSize: 0 + fileSize: 0, + lastModified: "" ) ) ).eraseToAnyPublisher() } - public func addToDownloadQueue(blocks: [CourseBlock]) { - - } + public func addToDownloadQueue(blocks: [CourseBlock]) {} public func getDownloadTasks() -> [DownloadDataTask] { [] @@ -639,34 +835,20 @@ public class DownloadManagerMock: DownloadManagerProtocol { } } - public func cancelDownloading(courseId: String, blocks: [CourseBlock]) async throws { - - } + public func cancelDownloading(courseId: String, blocks: [CourseBlock]) async throws {} - public func cancelDownloading(task: DownloadDataTask) { + public func cancelDownloading(task: DownloadDataTask) {} - } + public func cancelDownloading(courseId: String) async {} - public func cancelDownloading(courseId: String) async { + public func cancelAllDownloading() async throws {} - } + public func resumeDownloading() {} - public func cancelAllDownloading() async throws { + public func deleteFile(blocks: [CourseBlock]) {} - } + public func deleteAllFiles() {} - public func resumeDownloading() { - - } - - public func deleteFile(blocks: [CourseBlock]) { - - } - - public func deleteAllFiles() { - - } - public func fileUrl(for blockId: String) -> URL? { return nil } @@ -675,9 +857,7 @@ public class DownloadManagerMock: DownloadManagerProtocol { false } - public func removeAppSupportDirectoryUnusedContent() { - - } + public func removeAppSupportDirectoryUnusedContent() {} } #endif // swiftlint:enable file_length diff --git a/Core/Core/Network/OfflineSyncEndpoint.swift b/Core/Core/Network/OfflineSyncEndpoint.swift new file mode 100644 index 000000000..ce3f680ac --- /dev/null +++ b/Core/Core/Network/OfflineSyncEndpoint.swift @@ -0,0 +1,64 @@ +// +// OfflineSyncEndpoint.swift +// Core +// +// Created by  Stepanok Ivan on 20.06.2024. +// + +import Foundation +import Alamofire + +enum OfflineSyncEndpoint: EndPointType { + case submitOfflineProgress(courseID: String, blockID: String, data: String) + + var path: String { + switch self { + case let .submitOfflineProgress(courseID, blockID, _): + return "/courses/\(courseID)/xblock/\(blockID)/handler/xmodule_handler/problem_check" + } + } + + var httpMethod: HTTPMethod { + switch self { + case .submitOfflineProgress: + return .post + } + } + + var headers: HTTPHeaders? { + nil + } + + var task: HTTPTask { + switch self { + case let .submitOfflineProgress(_, _, data): + return .requestParameters(parameters: decode(query: data), encoding: URLEncoding.httpBody) + } + } + + func decode(query: String) -> Parameters { + var parameters: Parameters = [:] + + let pairs = query.split(separator: "&") + for pair in pairs { + let keyValue = pair.split(separator: "=") + if keyValue.count == 2 { + let key = String(keyValue[0]).removingPercentEncoding! + let value = String(keyValue[1]).removingPercentEncoding! + + if key.hasSuffix("[]") { + let trimmedKey = String(key.dropLast(2)) + if parameters[trimmedKey] == nil { + parameters[trimmedKey] = [value] + } else if var existingArray = parameters[trimmedKey] as? [String] { + existingArray.append(value) + parameters[trimmedKey] = existingArray + } + } else { + parameters[key] = value + } + } + } + return parameters + } +} diff --git a/Core/Core/Network/OfflineSyncManager.swift b/Core/Core/Network/OfflineSyncManager.swift new file mode 100644 index 000000000..6e9cdd30f --- /dev/null +++ b/Core/Core/Network/OfflineSyncManager.swift @@ -0,0 +1,85 @@ +// +// OfflineSyncManager.swift +// Core +// +// Created by  Stepanok Ivan on 20.06.2024. +// + +import Foundation +import WebKit +import Combine +import Swinject + +public protocol OfflineSyncManagerProtocol { + func handleMessage(message: WKScriptMessage, blockID: String) + func syncOfflineProgress() async +} + +public class OfflineSyncManager: OfflineSyncManagerProtocol { + + let persistence: CorePersistenceProtocol + let interactor: OfflineSyncInteractorProtocol + let connectivity: ConnectivityProtocol + private var cancellables = Set() + + public init( + persistence: CorePersistenceProtocol, + interactor: OfflineSyncInteractorProtocol, + connectivity: ConnectivityProtocol + ) { + self.persistence = persistence + self.interactor = interactor + self.connectivity = connectivity + + self.connectivity.internetReachableSubject.sink(receiveValue: { state in + switch state { + case .reachable: + Task(priority: .low) { + await self.syncOfflineProgress() + } + case .notReachable, nil: + break + } + }).store(in: &cancellables) + } + + public func handleMessage(message: WKScriptMessage, blockID: String) { + if message.name == "IOSBridge", + let progressJson = message.body as? String { + persistence.saveOfflineProgress( + progress: OfflineProgress( + progressJson: progressJson + ) + ) + var correctedProgressJson = progressJson + correctedProgressJson = correctedProgressJson.removingPercentEncoding ?? correctedProgressJson + message.webView?.evaluateJavaScript("markProblemCompleted('\(correctedProgressJson)')") + } else if let offlineProgress = persistence.loadProgress(for: blockID) { + var correctedProgressJson = offlineProgress.progressJson + correctedProgressJson = correctedProgressJson.removingPercentEncoding ?? correctedProgressJson + message.webView?.evaluateJavaScript("markProblemCompleted('\(correctedProgressJson)')") + } + } + + public func syncOfflineProgress() async { + let offlineProgress = persistence.loadAllOfflineProgress() + let cookies = HTTPCookieStorage.shared.cookies + HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } + for progress in offlineProgress { + do { + if try await interactor.submitOfflineProgress( + courseID: progress.courseID, + blockID: progress.blockID, + data: progress.data + ) { + persistence.deleteProgress(for: progress.blockID) + } + if let config = Container.shared.resolve(ConfigProtocol.self), let cookies { + HTTPCookieStorage.shared.setCookies(cookies, for: config.baseURL, mainDocumentURL: nil) + } + } catch { + debugLog("Error submitting offline progress: \(error.localizedDescription)") + } + } + } +} diff --git a/Core/Core/SwiftGen/Assets.swift b/Core/Core/SwiftGen/Assets.swift index 6f38ed569..f5bede856 100644 --- a/Core/Core/SwiftGen/Assets.swift +++ b/Core/Core/SwiftGen/Assets.swift @@ -103,8 +103,10 @@ public enum CoreAssets { public static let certificateBadge = ImageAsset(name: "certificateBadge") public static let check = ImageAsset(name: "check") public static let checkEmail = ImageAsset(name: "checkEmail") + public static let checkCircle = ImageAsset(name: "check_circle") public static let chevronRight = ImageAsset(name: "chevron_right") public static let clearInput = ImageAsset(name: "clearInput") + public static let download = ImageAsset(name: "download") public static let edit = ImageAsset(name: "edit") public static let favorite = ImageAsset(name: "favorite") public static let finishedSequence = ImageAsset(name: "finished_sequence") @@ -123,11 +125,14 @@ public enum CoreAssets { public static let noWifiMini = ImageAsset(name: "noWifiMini") public static let notAvaliable = ImageAsset(name: "notAvaliable") public static let playVideo = ImageAsset(name: "playVideo") + public static let remove = ImageAsset(name: "remove") + public static let reportOctagon = ImageAsset(name: "report_octagon") public static let resumeCourse = ImageAsset(name: "resumeCourse") public static let settings = ImageAsset(name: "settings") public static let star = ImageAsset(name: "star") public static let starOutline = ImageAsset(name: "star_outline") public static let viewAll = ImageAsset(name: "viewAll") + public static let visibility = ImageAsset(name: "visibility") public static let warning = ImageAsset(name: "warning") public static let warningFilled = ImageAsset(name: "warning_filled") } diff --git a/Core/Core/View/Base/FileWebView.swift b/Core/Core/View/Base/FileWebView.swift new file mode 100644 index 000000000..b6b98f4e5 --- /dev/null +++ b/Core/Core/View/Base/FileWebView.swift @@ -0,0 +1,65 @@ +// +// FileWebView.swift +// Core +// +// Created by  Stepanok Ivan on 08.07.2024. +// + +import Foundation +import WebKit +import SwiftUI + +public struct FileWebView: UIViewRepresentable { + public func makeUIView(context: Context) -> WKWebView { + let webview = WKWebView() + webview.scrollView.bounces = false + webview.scrollView.alwaysBounceHorizontal = false + webview.scrollView.showsHorizontalScrollIndicator = false + webview.scrollView.isScrollEnabled = true + webview.configuration.suppressesIncrementalRendering = true + webview.isOpaque = false + webview.configuration.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs") + webview.configuration.defaultWebpagePreferences.allowsContentJavaScript = true + webview.backgroundColor = .clear + webview.scrollView.backgroundColor = UIColor.white + webview.scrollView.alwaysBounceVertical = false + webview.scrollView.layer.cornerRadius = 24 + webview.scrollView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] + + if let url = URL(string: viewModel.url) { + + if let fileURL = URL(string: url.absoluteString) { + let fileAccessURL = fileURL.deletingLastPathComponent() + if let pdfData = try? Data(contentsOf: url) { + webview.load( + pdfData, + mimeType: "application/pdf", + characterEncodingName: "", + baseURL: fileAccessURL + ) + } + } + } + + return webview + } + + public func updateUIView(_ webview: WKWebView, context: Context) { + + } + + public class ViewModel: ObservableObject { + + @Published var url: String + + public init(url: String) { + self.url = url + } + } + + @ObservedObject var viewModel: ViewModel + + public init(viewModel: ViewModel) { + self.viewModel = viewModel + } +} diff --git a/Core/Core/View/Base/WebBrowser.swift b/Core/Core/View/Base/WebBrowser.swift index 9d6a113ac..cd61dcdb6 100644 --- a/Core/Core/View/Base/WebBrowser.swift +++ b/Core/Core/View/Base/WebBrowser.swift @@ -17,11 +17,18 @@ public struct WebBrowser: View { private var url: String private var pageTitle: String private var showProgress: Bool + private let connectivity: ConnectivityProtocol - public init(url: String, pageTitle: String, showProgress: Bool = false) { + public init( + url: String, + pageTitle: String, + showProgress: Bool = false, + connectivity: ConnectivityProtocol + ) { self.url = url self.pageTitle = pageTitle self.showProgress = showProgress + self.connectivity = connectivity } public var body: some View { @@ -57,10 +64,13 @@ public struct WebBrowser: View { viewModel: .init( url: url, baseURL: "", + openFile: {_ in}, injections: [.colorInversionCss, .readability, .accessibility] ), isLoading: $isLoading, - refreshCookies: {} + refreshCookies: { + }, + connectivity: connectivity ) .accessibilityIdentifier("web_browser") } @@ -71,6 +81,6 @@ public struct WebBrowser: View { struct WebBrowser_Previews: PreviewProvider { static var previews: some View { - WebBrowser(url: "", pageTitle: "") + WebBrowser(url: "", pageTitle: "", connectivity: Connectivity()) } } diff --git a/Core/Core/View/Base/WebUnitView.swift b/Core/Core/View/Base/WebUnitView.swift index a00b3a4b2..56d392abf 100644 --- a/Core/Core/View/Base/WebUnitView.swift +++ b/Core/Core/View/Base/WebUnitView.swift @@ -10,23 +10,38 @@ import SwiftUI import Theme public struct WebUnitView: View { - + @StateObject private var viewModel: WebUnitViewModel @State private var isWebViewLoading = false - + private var url: String private var injections: [WebviewInjection]? - + private let connectivity: ConnectivityProtocol + private var blockID: String + @State private var isFileOpen: Bool = false + @State private var dataUrl: String? + @State private var fileUrl: String = "" + public init( url: String, + dataUrl: String?, viewModel: WebUnitViewModel, - injections: [WebviewInjection]? + connectivity: ConnectivityProtocol, + injections: [WebviewInjection]?, + blockID: String ) { self._viewModel = .init( wrappedValue: viewModel ) self.url = url + self.dataUrl = dataUrl + self.connectivity = connectivity self.injections = injections + self.blockID = blockID + + if !self.connectivity.isInternetAvaliable, let dataUrl { + self.url = dataUrl + } } @ViewBuilder @@ -62,11 +77,14 @@ public struct WebUnitView: View { ZStack(alignment: .center) { GeometryReader { reader in ScrollView { - if viewModel.cookiesReady { + if viewModel.cookiesReady || dataUrl != nil { WebView( viewModel: .init( url: url, baseURL: viewModel.config.baseURL.absoluteString, + openFile: { file in + self.fileUrl = file + }, injections: injections ), isLoading: $isWebViewLoading, @@ -74,6 +92,10 @@ public struct WebUnitView: View { await viewModel.updateCookies( force: true ) + }, + connectivity: connectivity, + message: { message in + viewModel.syncManager.handleMessage(message: message, blockID: blockID) } ) .frame( @@ -85,6 +107,32 @@ public struct WebUnitView: View { .introspect(.scrollView, on: .iOS(.v15...), customize: { scrollView in scrollView.isScrollEnabled = false }) + .onChange(of: self.fileUrl, perform: { file in + if file != "" { + self.isFileOpen = true + } + }) + .sheet(isPresented: $isFileOpen, onDismiss: { self.fileUrl = ""; isFileOpen = false }, content: { + GeometryReader { reader2 in + ZStack(alignment: .topTrailing) { + ScrollView { + FileWebView(viewModel: FileWebView.ViewModel(url: fileUrl)) + .frame(width: reader2.size.width, height: reader2.size.height) + } + Button(action: { + isFileOpen = false + }, label: { + ZStack { + Circle().frame(width: 32, height: 32) + .foregroundColor(.white) + .shadow(color: .black.opacity(0.2), radius: 12) + Image(systemName: "xmark").renderingMode(.template) + .foregroundColor(.black) + }.padding(16) + }) + } + } + }) if viewModel.updatingCookies || isWebViewLoading { VStack { ProgressBar(size: 40, lineWidth: 8) @@ -94,7 +142,9 @@ public struct WebUnitView: View { } }.onFirstAppear { Task { - await viewModel.updateCookies() + if dataUrl == nil { + await viewModel.updateCookies() + } } } } diff --git a/Core/Core/View/Base/WebUnitViewModel.swift b/Core/Core/View/Base/WebUnitViewModel.swift index 6a76a6ee2..e8bc585c3 100644 --- a/Core/Core/View/Base/WebUnitViewModel.swift +++ b/Core/Core/View/Base/WebUnitViewModel.swift @@ -12,6 +12,7 @@ public class WebUnitViewModel: ObservableObject, WebviewCookiesUpdateProtocol { public let authInteractor: AuthInteractorProtocol let config: ConfigProtocol + let syncManager: OfflineSyncManagerProtocol @Published public var updatingCookies: Bool = false @Published public var cookiesReady: Bool = false @@ -26,8 +27,13 @@ public class WebUnitViewModel: ObservableObject, WebviewCookiesUpdateProtocol { } } - public init(authInteractor: AuthInteractorProtocol, config: ConfigProtocol) { + public init( + authInteractor: AuthInteractorProtocol, + config: ConfigProtocol, + syncManager: OfflineSyncManagerProtocol + ) { self.authInteractor = authInteractor self.config = config + self.syncManager = syncManager } } diff --git a/Core/Core/View/Base/Webview/WebView.swift b/Core/Core/View/Base/Webview/WebView.swift index 1b12167db..78d974b29 100644 --- a/Core/Core/View/Base/Webview/WebView.swift +++ b/Core/Core/View/Base/Webview/WebView.swift @@ -28,10 +28,17 @@ public struct WebView: UIViewRepresentable { @Published var url: String let baseURL: String let injections: [WebviewInjection]? + var openFile: (String) -> Void - public init(url: String, baseURL: String, injections: [WebviewInjection]? = nil) { + public init( + url: String, + baseURL: String, + openFile: @escaping (String) -> Void, + injections: [WebviewInjection]? = nil + ) { self.url = url self.baseURL = baseURL + self.openFile = openFile self.injections = injections } } @@ -39,21 +46,28 @@ public struct WebView: UIViewRepresentable { @ObservedObject var viewModel: ViewModel @Binding public var isLoading: Bool var webViewNavDelegate: WebViewNavigationDelegate? + let connectivity: ConnectivityProtocol + var message: ((WKScriptMessage) -> Void) var refreshCookies: () async -> Void var webViewType: String? + private let userContentControllerName = "IOSBridge" public init( viewModel: ViewModel, isLoading: Binding, refreshCookies: @escaping () async -> Void, navigationDelegate: WebViewNavigationDelegate? = nil, + connectivity: ConnectivityProtocol, + message: @escaping ((WKScriptMessage) -> Void) = { _ in }, webViewType: String? = nil ) { self.viewModel = viewModel self._isLoading = isLoading self.refreshCookies = refreshCookies self.webViewNavDelegate = navigationDelegate + self.connectivity = connectivity + self.message = message self.webViewType = webViewType } @@ -133,6 +147,13 @@ public struct WebView: UIViewRepresentable { guard let url = navigationAction.request.url else { return .cancel } + if url.absoluteString.starts(with: "file:///") { + if url.pathExtension == "pdf" { + await parent.viewModel.openFile(url.absoluteString) + return .cancel + } + } + let isWebViewDelegateHandled = await ( parent.webViewNavDelegate?.webView( webView, @@ -165,18 +186,20 @@ public struct WebView: UIViewRepresentable { _ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse ) async -> WKNavigationResponsePolicy { - guard let response = (navigationResponse.response as? HTTPURLResponse), - let url = response.url else { - return .cancel - } - let baseURL = await parent.viewModel.baseURL - - if (401...404).contains(response.statusCode) || url.absoluteString.hasPrefix(baseURL + "/login") { - await parent.refreshCookies() - DispatchQueue.main.async { - if let url = webView.url { - let request = URLRequest(url: url) - webView.load(request) + if parent.connectivity.isInternetAvaliable { + guard let response = (navigationResponse.response as? HTTPURLResponse), + let url = response.url else { + return .cancel + } + let baseURL = await parent.viewModel.baseURL + + if (401...404).contains(response.statusCode) || url.absoluteString.hasPrefix(baseURL + "/login") { + await parent.refreshCookies() + DispatchQueue.main.async { + if let url = webView.url { + let request = URLRequest(url: url) + webView.load(request) + } } } } @@ -217,9 +240,14 @@ public struct WebView: UIViewRepresentable { _ userContentController: WKUserContentController, didReceive message: WKScriptMessage ) { + self.parent.message(message) parent.viewModel.injections?.handle(message: message) } } + + public func webView(_ webView: WKWebView, shouldPreviewElement elementInfo: WKContextMenuElementInfo) -> Bool { + return true + } private var userAgent: String { let info = Bundle.main.infoDictionary @@ -238,6 +266,9 @@ public struct WebView: UIViewRepresentable { public func makeUIView(context: UIViewRepresentableContext) -> WKWebView { let webViewConfig = WKWebViewConfiguration() + webViewConfig.userContentController.add(context.coordinator, name: userContentControllerName) + webViewConfig.defaultWebpagePreferences.allowsContentJavaScript = true + webViewConfig.preferences.setValue(true, forKey: "allowFileAccessFromFileURLs") let webView = WKWebView(frame: .zero, configuration: webViewConfig) #if DEBUG @@ -260,7 +291,6 @@ 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) - // 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) diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index 2ec932caa..bd2e05ca1 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -7,7 +7,8 @@ objects = { /* Begin PBXBuildFile section */ - 02197DC52C1AFC0600CC8FF2 /* CalendarSyncStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EBC7542C19CFCF00BE182C /* CalendarSyncStatusView.swift */; }; + 02228B2F2C221412009A5F28 /* LargestDownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02228B2E2C221412009A5F28 /* LargestDownloadsView.swift */; }; + 02197DC52C1AFC0600CC8FF2 /* CalendarSyncStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EBC7542C19CFCF00BE182C /* CalendarSyncStatusView.swift */; }; 02280F5E294B4FDA0032823A /* CourseCoreModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 02280F5C294B4FDA0032823A /* CourseCoreModel.xcdatamodeld */; }; 02280F60294B50030032823A /* CoursePersistenceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02280F5F294B50030032823A /* CoursePersistenceProtocol.swift */; }; 022C64D829ACEC48000F532B /* HandoutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022C64D729ACEC48000F532B /* HandoutsView.swift */; }; @@ -37,6 +38,7 @@ 0270210328E736E700F54332 /* CourseOutlineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0270210128E736E700F54332 /* CourseOutlineView.swift */; }; 0276D75B29DDA3890004CDF8 /* Data_ResumeBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0276D75A29DDA3890004CDF8 /* Data_ResumeBlock.swift */; }; 0276D75D29DDA3F80004CDF8 /* ResumeBlock.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0276D75C29DDA3F80004CDF8 /* ResumeBlock.swift */; }; + 02868AE52C19FE0B0003E339 /* DownloadActionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02868AE42C19FE0B0003E339 /* DownloadActionView.swift */; }; 0289F90228E1C3E10064F8F3 /* swiftgen.yml in Resources */ = {isa = PBXBuildFile; fileRef = 0289F90128E1C3E00064F8F3 /* swiftgen.yml */; }; 0295B1D9297E6DF8003B0C65 /* CourseUnitViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0295B1D8297E6DF8003B0C65 /* CourseUnitViewModelTests.swift */; }; 0295C889299BBE8200ABE571 /* CourseNavigationView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0295C888299BBE8200ABE571 /* CourseNavigationView.swift */; }; @@ -48,14 +50,19 @@ 02B6B3C928E1E68100232911 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02B6B3C828E1E68100232911 /* Core.framework */; }; 02BB20182BFCE7B200364948 /* CustomDisclosureGroup.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02BB20172BFCE7B200364948 /* CustomDisclosureGroup.swift */; }; 02C355392C08DCD700501342 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = 02C355372C08DCD700501342 /* Localizable.stringsdict */; }; + 02C7B1D82C271A7000D2A7BB /* OfflineContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C7B1D72C271A7000D2A7BB /* OfflineContentView.swift */; }; 02D4FC2E2BBD7C9C00C47748 /* MessageSectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D4FC2D2BBD7C9C00C47748 /* MessageSectionView.swift */; }; 02E3803E2BFF9F0A00815AFA /* CourseProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E3803D2BFF9F0A00815AFA /* CourseProgressView.swift */; }; 02F0144F28F46474002E513D /* CourseContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F0144E28F46474002E513D /* CourseContainerView.swift */; }; 02F0145728F4A2FF002E513D /* CourseContainerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F0145628F4A2FF002E513D /* CourseContainerViewModel.swift */; }; 02F3BFDD29252E900051930C /* CourseRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F3BFDC29252E900051930C /* CourseRouter.swift */; }; + 02F71B4A2C1B163B00FF936A /* DownloadErrorAlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F71B492C1B163A00FF936A /* DownloadErrorAlertView.swift */; }; + 02F71B4C2C1B200900FF936A /* DeviceStorageFullAlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F71B4B2C1B200900FF936A /* DeviceStorageFullAlertView.swift */; }; 02F78AEB29E6BCA20038DE30 /* VideoPlayerViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F78AEA29E6BCA20038DE30 /* VideoPlayerViewModelTests.swift */; }; 02F98A8128F8224200DE94C0 /* Discussion.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 02F98A8028F8224200DE94C0 /* Discussion.framework */; }; 02FCB2B32BBEB36600373180 /* CourseHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FCB2B22BBEB36600373180 /* CourseHeaderView.swift */; }; + 02FF6FA72C20BFF800E44DD8 /* OfflineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FF6FA62C20BFF800E44DD8 /* OfflineView.swift */; }; + 02FF6FAA2C20D56A00E44DD8 /* TotalDownloadedProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02FF6FA92C20D56A00E44DD8 /* TotalDownloadedProgressView.swift */; }; 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 */; }; @@ -108,6 +115,7 @@ /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ + 02228B2E2C221412009A5F28 /* LargestDownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargestDownloadsView.swift; sourceTree = ""; }; 02280F5D294B4FDA0032823A /* CourseCoreModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CourseCoreModel.xcdatamodel; sourceTree = ""; }; 02280F5F294B50030032823A /* CoursePersistenceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoursePersistenceProtocol.swift; sourceTree = ""; }; 022C64D729ACEC48000F532B /* HandoutsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HandoutsView.swift; sourceTree = ""; }; @@ -137,6 +145,7 @@ 0270210128E736E700F54332 /* CourseOutlineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseOutlineView.swift; sourceTree = ""; }; 0276D75A29DDA3890004CDF8 /* Data_ResumeBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_ResumeBlock.swift; sourceTree = ""; }; 0276D75C29DDA3F80004CDF8 /* ResumeBlock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResumeBlock.swift; sourceTree = ""; }; + 02868AE42C19FE0B0003E339 /* DownloadActionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadActionView.swift; sourceTree = ""; }; 0289F8EE28E1C3510064F8F3 /* Course.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Course.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0289F90128E1C3E00064F8F3 /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = ""; }; 0295B1D8297E6DF8003B0C65 /* CourseUnitViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseUnitViewModelTests.swift; sourceTree = ""; }; @@ -151,15 +160,20 @@ 02BB20172BFCE7B200364948 /* CustomDisclosureGroup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomDisclosureGroup.swift; sourceTree = ""; }; 02C355382C08DCD700501342 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = ""; }; 02C3553A2C08DCE000501342 /* uk */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = uk; path = uk.lproj/Localizable.stringsdict; sourceTree = ""; }; + 02C7B1D72C271A7000D2A7BB /* OfflineContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineContentView.swift; sourceTree = ""; }; 02D4FC2D2BBD7C9C00C47748 /* MessageSectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MessageSectionView.swift; sourceTree = ""; }; 02E3803D2BFF9F0A00815AFA /* CourseProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseProgressView.swift; sourceTree = ""; }; 02EBC7542C19CFCF00BE182C /* CalendarSyncStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarSyncStatusView.swift; 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 = ""; }; 02F3BFDC29252E900051930C /* CourseRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseRouter.swift; sourceTree = ""; }; + 02F71B492C1B163A00FF936A /* DownloadErrorAlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadErrorAlertView.swift; sourceTree = ""; }; + 02F71B4B2C1B200900FF936A /* DeviceStorageFullAlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeviceStorageFullAlertView.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; }; 02FCB2B22BBEB36600373180 /* CourseHeaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseHeaderView.swift; sourceTree = ""; }; + 02FF6FA62C20BFF800E44DD8 /* OfflineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineView.swift; sourceTree = ""; }; + 02FF6FA92C20D56A00E44DD8 /* TotalDownloadedProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TotalDownloadedProgressView.swift; sourceTree = ""; }; 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 = ""; }; @@ -276,10 +290,21 @@ 02454CA92A2619B40043052A /* LessonProgressView.swift */, BAD9CA2C2B2736BB00DE790A /* LessonLineProgressView.swift */, 060E8BC92B5FD68C0080C952 /* UnitStack.swift */, + 02C7B1D72C271A7000D2A7BB /* OfflineContentView.swift */, ); path = Subviews; sourceTree = ""; }; + 02868AE32C19FDF10003E339 /* ActionViews */ = { + isa = PBXGroup; + children = ( + 02868AE42C19FE0B0003E339 /* DownloadActionView.swift */, + 02F71B492C1B163A00FF936A /* DownloadErrorAlertView.swift */, + 02F71B4B2C1B200900FF936A /* DeviceStorageFullAlertView.swift */, + ); + path = ActionViews; + sourceTree = ""; + }; 0289F8E428E1C3510064F8F3 = { isa = PBXGroup; children = ( @@ -397,6 +422,7 @@ 02EAE2CA28E1F0A700529644 /* Presentation */ = { isa = PBXGroup; children = ( + 02FF6FA52C20BFE100E44DD8 /* Offline */, DB7D6EAA2ADFCAA00036BB13 /* Dates */, 070019A828F6F33600D5FC78 /* Container */, 070019A728F6F2D600D5FC78 /* Outline */, @@ -411,6 +437,24 @@ path = Presentation; sourceTree = ""; }; + 02FF6FA52C20BFE100E44DD8 /* Offline */ = { + isa = PBXGroup; + children = ( + 02FF6FA62C20BFF800E44DD8 /* OfflineView.swift */, + 02FF6FA82C20D53C00E44DD8 /* Subviews */, + ); + path = Offline; + sourceTree = ""; + }; + 02FF6FA82C20D53C00E44DD8 /* Subviews */ = { + isa = PBXGroup; + children = ( + 02FF6FA92C20D56A00E44DD8 /* TotalDownloadedProgressView.swift */, + 02228B2E2C221412009A5F28 /* LargestDownloadsView.swift */, + ); + path = Subviews; + sourceTree = ""; + }; 068DDA5A2B1E198700FF8CCB /* DropdownList */ = { isa = PBXGroup; children = ( @@ -578,6 +622,7 @@ BAD9CA482B2C88D500DE790A /* Subviews */ = { isa = PBXGroup; children = ( + 02868AE32C19FDF10003E339 /* ActionViews */, 9784D4762BF39EFD00AFEFFF /* DatesSuccessView */, 9784D4752BF39EEF00AFEFFF /* CalendarSyncProgressView */, 02D4FC2C2BBD7C7500C47748 /* MessageSectionView */, @@ -853,6 +898,7 @@ BAD9CA4A2B2C88E000DE790A /* CourseVideoDownloadBarView.swift in Sources */, 022C64DE29AD167A000F532B /* HandoutsUpdatesDetailView.swift in Sources */, BA58CF612B471041005B102E /* VideoDownloadQualityBarView.swift in Sources */, + 02FF6FA72C20BFF800E44DD8 /* OfflineView.swift in Sources */, 0270210328E736E700F54332 /* CourseOutlineView.swift in Sources */, 068DDA602B1E198700FF8CCB /* CourseUnitVerticalsDropdownView.swift in Sources */, 067B7B512BED339200D1768F /* PipManagerProtocol.swift in Sources */, @@ -867,9 +913,12 @@ 02280F60294B50030032823A /* CoursePersistenceProtocol.swift in Sources */, 02454CAA2A2619B40043052A /* LessonProgressView.swift in Sources */, 975F475E2B6151FD00E5B031 /* CourseDatesMock.swift in Sources */, + DB7D6EB22ADFE9510036BB13 /* Data_CourseDates.swift in Sources */, + 02FF6FAA2C20D56A00E44DD8 /* TotalDownloadedProgressView.swift in Sources */, 975F47602B615DA700E5B031 /* CourseStructureMock.swift in Sources */, 02280F5E294B4FDA0032823A /* CourseCoreModel.xcdatamodeld in Sources */, 0766DFCE299AB26D00EBEF6A /* EncodedVideoPlayer.swift in Sources */, + 02C7B1D82C271A7000D2A7BB /* OfflineContentView.swift in Sources */, 0276D75B29DDA3890004CDF8 /* Data_ResumeBlock.swift in Sources */, 0276D75D29DDA3F80004CDF8 /* ResumeBlock.swift in Sources */, 02F3BFDD29252E900051930C /* CourseRouter.swift in Sources */, @@ -886,15 +935,19 @@ 067B7B4F2BED339200D1768F /* PlayerDelegateProtocol.swift in Sources */, 0231124D28EDA804002588FB /* CourseUnitView.swift in Sources */, 027020FC28E7362100F54332 /* Data_CourseOutlineResponse.swift in Sources */, + 02F71B4A2C1B163B00FF936A /* DownloadErrorAlertView.swift in Sources */, + DB7D6EB02ADFDA0E0036BB13 /* CourseDates.swift in Sources */, 067B7B532BED339200D1768F /* PlayerServiceProtocol.swift in Sources */, BAD9CA2D2B2736BB00DE790A /* LessonLineProgressView.swift in Sources */, 02197DC52C1AFC0600CC8FF2 /* CalendarSyncStatusView.swift in Sources */, 060E8BCA2B5FD68C0080C952 /* UnitStack.swift in Sources */, + 02228B2F2C221412009A5F28 /* LargestDownloadsView.swift in Sources */, 0295C889299BBE8200ABE571 /* CourseNavigationView.swift in Sources */, 06FD7EDF2B1F29F3008D632B /* CourseVerticalImageView.swift in Sources */, BAC0E0DB2B32F0AE006B68A9 /* CourseVideoDownloadBarViewModel.swift in Sources */, DB7D6EAE2ADFCB4A0036BB13 /* CourseDatesViewModel.swift in Sources */, 067B7B522BED339200D1768F /* SubtitlesView.swift in Sources */, + 02868AE52C19FE0B0003E339 /* DownloadActionView.swift in Sources */, 07DE59862BECB868001CBFBC /* CourseAnalytics.swift in Sources */, 02BB20182BFCE7B200364948 /* CustomDisclosureGroup.swift in Sources */, 022C64E229ADEB83000F532B /* CourseUpdate.swift in Sources */, @@ -917,6 +970,8 @@ 02E3803E2BFF9F0A00815AFA /* CourseProgressView.swift in Sources */, 022F8E162A1DFBC6008EFAB9 /* YouTubeVideoPlayerViewModel.swift in Sources */, 02B6B3BE28E1D15C00232911 /* CourseEndpoint.swift in Sources */, + 97EA4D862B85034D00663F58 /* CalendarManager.swift in Sources */, + 02F71B4C2C1B200900FF936A /* DeviceStorageFullAlertView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index dc90d9bd4..c068f4722 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -214,6 +214,20 @@ public class CourseRepository: CourseRepositoryProtocol { .replacingOccurrences(of: "?lang=\($0.key)", with: "") return SubtitleUrl(language: $0.key, url: url) } + + var offlineDownload: OfflineDownload? + + if let offlineData = block.offlineDownload, + let fileUrl = offlineData.fileUrl, + let lastModified = offlineData.lastModified, + let fileSize = offlineData.fileSize { + let fullUrl = fileUrl.starts(with: "http") ? fileUrl : config.baseURL.absoluteString + fileUrl + offlineDownload = OfflineDownload( + fileUrl: fullUrl, + lastModified: lastModified, + fileSize: fileSize + ) + } return CourseBlock( blockId: block.blockId, @@ -236,7 +250,8 @@ public class CourseRepository: CourseRepositoryProtocol { mobileLow: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.mobileLow), hls: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.hls) ), - multiDevice: block.multiDevice + multiDevice: block.multiDevice, + offlineDownload: offlineDownload ) } @@ -435,6 +450,19 @@ And there are various ways of describing it-- call it oral poetry or let url = $0.value return SubtitleUrl(language: $0.key, url: url) } + + var offlineDownload: OfflineDownload? + + if let offlineData = block.offlineDownload, + let fileUrl = offlineData.fileUrl, + let lastModified = offlineData.lastModified, + let fileSize = offlineData.fileSize { + offlineDownload = OfflineDownload( + fileUrl: fileUrl, + lastModified: lastModified, + fileSize: fileSize + ) + } return CourseBlock( blockId: block.blockId, @@ -457,7 +485,8 @@ And there are various ways of describing it-- call it oral poetry or mobileLow: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.mobileLow), hls: parseVideo(encodedVideo: block.userViewData?.encodedVideo?.hls) ), - multiDevice: block.multiDevice + multiDevice: block.multiDevice, + offlineDownload: offlineDownload ) } diff --git a/Course/Course/Data/Model/Data_CourseOutlineResponse.swift b/Course/Course/Data/Model/Data_CourseOutlineResponse.swift index 5cee8c3e0..a4fe30b96 100644 --- a/Course/Course/Data/Model/Data_CourseOutlineResponse.swift +++ b/Course/Course/Data/Model/Data_CourseOutlineResponse.swift @@ -84,6 +84,7 @@ public extension DataLayer { public let userViewData: CourseDetailUserViewData? public let multiDevice: Bool? public let assignmentProgress: AssignmentProgress? + public let offlineDownload: OfflineDownload? public init( blockId: String, @@ -99,7 +100,8 @@ public extension DataLayer { allSources: [String]?, userViewData: CourseDetailUserViewData?, multiDevice: Bool?, - assignmentProgress: AssignmentProgress? + assignmentProgress: AssignmentProgress?, + offlineDownload: OfflineDownload? ) { self.blockId = blockId self.id = id @@ -115,6 +117,7 @@ public extension DataLayer { self.userViewData = userViewData self.multiDevice = multiDevice self.assignmentProgress = assignmentProgress + self.offlineDownload = offlineDownload } public enum CodingKeys: String, CodingKey { @@ -127,6 +130,7 @@ public extension DataLayer { case allSources = "all_sources" case multiDevice = "student_view_multi_device" case assignmentProgress = "assignment_progress" + case offlineDownload = "offline_download" } } @@ -147,6 +151,24 @@ public extension DataLayer { self.numPointsPossible = numPointsPossible } } + + struct OfflineDownload: Codable { + public let fileUrl: String? + public let lastModified: String? + public let fileSize: Int? + + public enum CodingKeys: String, CodingKey { + case fileUrl = "file_url" + case lastModified = "last_modified" + case fileSize = "file_size" + } + + public init(fileUrl: String?, lastModified: String?, fileSize: Int?) { + self.fileUrl = fileUrl + self.lastModified = lastModified + self.fileSize = fileSize + } + } struct Transcripts: Codable { public let en: String? diff --git a/Course/Course/Data/Network/CourseEndpoint.swift b/Course/Course/Data/Network/CourseEndpoint.swift index 6ce7a048a..323ae5e8d 100644 --- a/Course/Course/Data/Network/CourseEndpoint.swift +++ b/Course/Course/Data/Network/CourseEndpoint.swift @@ -24,7 +24,7 @@ enum CourseEndpoint: EndPointType { var path: String { switch self { case .getCourseBlocks: - return "/api/mobile/v3/course_info/blocks/" + return "/api/mobile/v4/course_info/blocks/" case .pageHTML(let url): return "/xblock/\(url)" case .blockCompletionRequest: @@ -82,7 +82,7 @@ enum CourseEndpoint: EndPointType { "username": userName, "course_id": courseID, "depth": "all", - "student_view_data": "video,discussion,html", + "student_view_data": "video,discussion,html,problem", "nav_depth": "4", "requested_fields": """ contains_gated_content,show_gated_sections,special_exam_info,graded, diff --git a/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents b/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents index d8e99bd3e..cb4d84738 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,8 +9,11 @@ + + + @@ -107,4 +110,4 @@ - + \ No newline at end of file diff --git a/Course/Course/Domain/CourseInteractor.swift b/Course/Course/Domain/CourseInteractor.swift index dcd9eac1d..e4498d024 100644 --- a/Course/Course/Domain/CourseInteractor.swift +++ b/Course/Course/Domain/CourseInteractor.swift @@ -13,6 +13,7 @@ public protocol CourseInteractorProtocol { func getCourseBlocks(courseID: String) async throws -> CourseStructure func getCourseVideoBlocks(fullStructure: CourseStructure) -> CourseStructure func getLoadedCourseBlocks(courseID: String) async throws -> CourseStructure + func getSequentialsContainsBlocks(blockIds: [String], courseID: String) async throws -> [CourseSequential] func blockCompletionRequest(courseID: String, blockID: String) async throws func getHandouts(courseID: String) async throws -> String? func getUpdates(courseID: String) async throws -> [CourseUpdate] @@ -67,6 +68,28 @@ public class CourseInteractor: CourseInteractorProtocol { return try await repository.getLoadedCourseBlocks(courseID: courseID) } + public func getSequentialsContainsBlocks(blockIds: [String], courseID: String) async throws -> [CourseSequential] { + let courseStructure = try await repository.getLoadedCourseBlocks(courseID: courseID) + var sequentials: [CourseSequential] = [] + + for chapter in courseStructure.childs { + for sequential in chapter.childs { + let filteredChilds = sequential.childs.filter { vertical in + vertical.childs.contains { block in + blockIds.contains(block.id) + } + } + if !filteredChilds.isEmpty { + var newSequential = sequential + newSequential.childs = filteredChilds + sequentials.append(newSequential) + } + } + } + + return sequentials + } + public func blockCompletionRequest(courseID: String, blockID: String) async throws { NotificationCenter.default.post(name: .onblockCompletionRequested, object: courseID) return try await repository.blockCompletionRequest(courseID: courseID, blockID: blockID) @@ -133,7 +156,7 @@ public class CourseInteractor: CourseInteractorProtocol { type: sequential.type, completion: sequential.completion, childs: newChilds, - sequentialProgress: sequential.sequentialProgress, + sequentialProgress: sequential.sequentialProgress, due: sequential.due ) } diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 6ffa4cf88..0b7c36ca1 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -230,6 +230,19 @@ public struct CourseContainerView: View { } .tag(tab) .accentColor(Theme.Colors.accentColor) + case .offline: + OfflineView( + courseID: courseID, + coordinate: $coordinate, + collapsed: $collapsed, + viewModel: viewModel + ) + .tabItem { + tab.image + Text(tab.title) + } + .tag(tab) + .accentColor(Theme.Colors.accentColor) case .discussion: DiscussionTopicsView( courseID: courseID, diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index 8f95d25b9..a15a53d98 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -17,6 +17,7 @@ public enum CourseTab: Int, CaseIterable, Identifiable { case course case videos case dates + case offline case discussion case handounds } @@ -30,13 +31,15 @@ extension CourseTab { return CourseLocalization.CourseContainer.videos case .dates: return CourseLocalization.CourseContainer.dates + case .offline: + return CourseLocalization.CourseContainer.offline case .discussion: return CourseLocalization.CourseContainer.discussions case .handounds: return CourseLocalization.CourseContainer.handouts } } - + public var image: Image { switch self { case .course: @@ -45,6 +48,8 @@ extension CourseTab { return CoreAssets.videos.swiftUIImage.renderingMode(.template) case .dates: return CoreAssets.dates.swiftUIImage.renderingMode(.template) + case .offline: + return CoreAssets.downloads.swiftUIImage.renderingMode(.template) case .discussion: return CoreAssets.discussions.swiftUIImage.renderingMode(.template) case .handounds: @@ -54,7 +59,7 @@ extension CourseTab { } public class CourseContainerViewModel: BaseCourseViewModel { - + @Published public var selection: Int @Published var isShowProgress = true @Published var isShowRefresh = false @@ -69,9 +74,14 @@ public class CourseContainerViewModel: BaseCourseViewModel { @Published var isInternetAvaliable: Bool = true @Published var dueDatesShifted: Bool = false @Published var updateCourseProgress: Bool = false + @Published var totalFilesSize: Int = 1 + @Published var downloadedFilesSize: Int = 0 + @Published var realDownloadedFilesSize: Int = 0 + @Published var largestDownloadBlocks: [CourseBlock] = [] + @Published var downloadAllButtonState: OfflineView.DownloadAllState = .start let completionPublisher = NotificationCenter.default.publisher(for: .onblockCompletionRequested) - + var errorMessage: String? { didSet { withAnimation { @@ -83,23 +93,25 @@ public class CourseContainerViewModel: BaseCourseViewModel { let router: CourseRouter let config: ConfigProtocol let connectivity: ConnectivityProtocol - + let isActive: Bool? let courseStart: Date? let courseEnd: Date? let enrollmentStart: Date? let enrollmentEnd: Date? let lastVisitedBlockID: String? - + var courseDownloadTasks: [DownloadDataTask] = [] private(set) var waitingDownloads: [CourseBlock]? - + private let interactor: CourseInteractorProtocol private let authInteractor: AuthInteractorProtocol let analytics: CourseAnalytics let coreAnalytics: CoreAnalytics private(set) var storage: CourseStorage - + + private let cellularFileSizeLimit: Int = 100 * 1024 * 1024 + public init( interactor: CourseInteractorProtocol, authInteractor: AuthInteractorProtocol, @@ -135,7 +147,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { self.lastVisitedBlockID = lastVisitedBlockID self.coreAnalytics = coreAnalytics self.selection = selection.rawValue - + super.init(manager: manager) addObservers() } @@ -146,7 +158,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { updateCourseProgress = false } } - + func openLastVisitedBlock() { guard let continueWith = continueWith, let courseStructure = courseStructure else { return } @@ -199,6 +211,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { } courseVideosStructure = interactor.getCourseVideoBlocks(fullStructure: courseStructure!) await setDownloadsStates() + await getDownloadingProgress() isShowProgress = false isShowRefresh = false @@ -228,7 +241,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { } } } - + @MainActor func shiftDueDates(courseID: String, withProgress: Bool = true, screen: DatesStatusInfoScreen, type: String) async { isShowProgress = withProgress @@ -272,7 +285,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { storage.userSettings?.downloadQuality = downloadQuality userSettings = storage.userSettings } - + @MainActor func tryToRefreshCookies() async { try? await authInteractor.getCookies(force: false) @@ -296,39 +309,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { } } } - - @MainActor - func onDownloadViewTap(chapter: CourseChapter, blockId: String, state: DownloadViewState) async { - guard let sequential = chapter.childs - .first(where: { $0.id == blockId }) else { - return - } - - let blocks = sequential.childs.flatMap { $0.childs } - .filter { $0.isDownloadable } - - if state == .available, isShowedAllowLargeDownloadAlert(blocks: blocks) { - return - } - - 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) - } - + func verticalsBlocksDownloadable(by courseSequential: CourseSequential) -> [CourseBlock] { let verticals = downloadableVerticals.filter { verticalState in courseSequential.childs.contains(where: { item in @@ -337,7 +318,7 @@ 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 @@ -345,7 +326,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { } return tasks } - + func continueDownload() async { guard let blocks = waitingDownloads else { return @@ -358,7 +339,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { } } } - + func trackSelectedTab( selection: CourseTab, courseId: String, @@ -369,6 +350,8 @@ public class CourseContainerViewModel: BaseCourseViewModel { analytics.courseOutlineCourseTabClicked(courseId: courseId, courseName: courseName) case .videos: analytics.courseOutlineVideosTabClicked(courseId: courseId, courseName: courseName) + case .offline: + analytics.courseOutlineOfflineTabClicked(courseId: courseId, courseName: courseName) case .dates: analytics.courseOutlineDatesTabClicked(courseId: courseId, courseName: courseName) case .discussion: @@ -377,7 +360,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { analytics.courseOutlineHandoutsTabClicked(courseId: courseId, courseName: courseName) } } - + func trackVerticalClicked( courseId: String, courseName: String, @@ -398,7 +381,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { courseID: courseID ) } - + func trackSequentialClicked(_ sequential: CourseSequential) { guard let course = courseStructure else { return } analytics.sequentialClicked( @@ -417,7 +400,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { blockId: blockId ) } - + func completeBlock( chapterID: String, sequentialID: String, @@ -433,14 +416,14 @@ public class CourseContainerViewModel: BaseCourseViewModel { .childs.firstIndex(where: { $0.id == sequentialID }) else { return } - + guard let verticalIndex = courseStructure? .childs[chapterIndex] .childs[sequentialIndex] .childs.firstIndex(where: { $0.id == verticalID }) else { return } - + guard let blockIndex = courseStructure? .childs[chapterIndex] .childs[sequentialIndex] @@ -448,7 +431,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { .childs.firstIndex(where: { $0.id == blockID }) else { return } - + courseStructure? .childs[chapterIndex] .childs[sequentialIndex] @@ -458,7 +441,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { courseVideosStructure = interactor.getCourseVideoBlocks(fullStructure: $0) } } - + func hasVideoForDowbloads() -> Bool { guard let courseVideosStructure = courseVideosStructure else { return false @@ -467,7 +450,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { .flatMap { $0.childs } .contains(where: { $0.isDownloadable }) } - + func isAllDownloading() -> Bool { let totalCount = downloadableVerticals.count let downloadingCount = downloadableVerticals.filter { $0.state == .downloading }.count @@ -475,9 +458,29 @@ public class CourseContainerViewModel: BaseCourseViewModel { if finishedCount == totalCount { return false } return totalCount - finishedCount == downloadingCount } - + + @MainActor + func isAllDownloaded() -> Bool { + guard let course = courseStructure else { return false } + for chapter in course.childs { + for sequential in chapter.childs where sequential.isDownloadable { + let blocks = downloadableBlocks(from: sequential) + for block in blocks { + if let task = courseDownloadTasks.first(where: { $0.blockId == block.id }) { + if task.state != .finished { + return false + } + } else { + return false + } + } + } + } + return true + } + @MainActor - func download(state: DownloadViewState, blocks: [CourseBlock]) async { + func download(state: DownloadViewState, blocks: [CourseBlock], sequentials: [CourseSequential]) async { do { switch state { case .available: @@ -485,7 +488,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { case .downloading: try await manager.cancelDownloading(courseId: courseStructure?.id ?? "", blocks: blocks) case .finished: - await manager.deleteFile(blocks: blocks) + presentRemoveDownloadAlert(blocks: blocks, sequentials: sequentials) } } catch let error { if error is NoWiFiError { @@ -493,7 +496,176 @@ public class CourseContainerViewModel: BaseCourseViewModel { } } } - + + private func presentNoInternetAlert(sequentials: [CourseSequential]) { + router.presentView( + transitionStyle: .coverVertical, + view: DownloadErrorAlertView( + errorType: .noInternetConnection, + sequentials: sequentials, + close: { [weak self] in + guard let self else { return } + self.router.dismiss(animated: true) + } + ), + completion: {} + ) + } + + private func presentWifiRequiredAlert(sequentials: [CourseSequential]) { + router.presentView( + transitionStyle: .coverVertical, + view: DownloadErrorAlertView( + errorType: .wifiRequired, + sequentials: sequentials, + close: { [weak self] in + guard let self else { return } + self.router.dismiss(animated: true) + } + ), + completion: {} + ) + } + + @MainActor + private func presentConfirmDownloadCellularAlert( + blocks: [CourseBlock], + sequentials: [CourseSequential], + totalFileSize: Int, + action: @escaping () -> Void = {} + ) async { + router.presentView( + transitionStyle: .coverVertical, + view: DownloadActionView( + actionType: .confirmDownloadCellular, + sequentials: sequentials, + action: { [weak self] in + guard let self else { return } + if !self.isEnoughSpace(for: totalFileSize) { + self.presentStorageFullAlert(sequentials: sequentials) + } else { + Task { + try? await self.manager.addToDownloadQueue(blocks: blocks) + } + action() + } + self.router.dismiss(animated: true) + }, + cancel: { [weak self] in + guard let self else { return } + self.router.dismiss(animated: true) + } + ), + completion: {} + ) + } + + private func presentStorageFullAlert(sequentials: [CourseSequential]) { + router.presentView( + transitionStyle: .coverVertical, + view: DeviceStorageFullAlertView( + sequentials: sequentials, + usedSpace: getUsedDiskSpace() ?? 0, + freeSpace: getFreeDiskSpace() ?? 0, + close: { [weak self] in + guard let self else { return } + self.router.dismiss(animated: true) + } + ), + completion: {} + ) + } + + @MainActor + private func presentConfirmDownloadAlert( + blocks: [CourseBlock], + sequentials: [CourseSequential], + totalFileSize: Int, + action: @escaping () -> Void = {} + ) async { + router.presentView( + transitionStyle: .coverVertical, + view: DownloadActionView( + actionType: .confirmDownload, + sequentials: manager.updateUnzippedFileSize(for: sequentials), + action: { [weak self] in + guard let self else { return } + if !self.isEnoughSpace(for: totalFileSize) { + self.router.dismiss(animated: true) + self.presentStorageFullAlert(sequentials: sequentials) + } else { + Task { + try? await self.manager.addToDownloadQueue(blocks: blocks) + } + action() + } + self.router.dismiss(animated: true) + }, + cancel: { [weak self] in + guard let self else { return } + self.router.dismiss(animated: true) + } + ), + completion: {} + ) + } + + private func presentRemoveDownloadAlert(blocks: [CourseBlock], sequentials: [CourseSequential]) { + router.presentView( + transitionStyle: .coverVertical, + view: DownloadActionView( + actionType: .remove, + sequentials: manager.updateUnzippedFileSize(for: sequentials), + action: { [weak self] in + guard let self else { return } + Task { + await self.manager.deleteFile(blocks: blocks) + } + self.router.dismiss(animated: true) + }, + cancel: { [weak self] in + guard let self else { return } + self.router.dismiss(animated: true) + } + ), + completion: {} + ) + } + + @MainActor + func collectBlocks(chapter: CourseChapter, blockId: String, state: DownloadViewState) async -> [CourseBlock] { + let sequentials = chapter.childs.filter({ $0.id == blockId }) + guard !sequentials.isEmpty else { return [] } + + let blocks = sequentials.flatMap { $0.childs.flatMap { $0.childs } } + .filter { $0.isDownloadable } + + if state == .available, isShowedAllowLargeDownloadAlert(blocks: blocks) { + return [] + } + + guard let sequential = chapter.childs.first(where: { $0.id == blockId }) else { + 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 + ) + } + + return blocks + } + @MainActor func isShowedAllowLargeDownloadAlert(blocks: [CourseBlock]) -> Bool { waitingDownloads = nil @@ -518,7 +690,94 @@ public class CourseContainerViewModel: BaseCourseViewModel { } return false } - + + @MainActor + func downloadAll() async { + guard let course = courseStructure else { return } + var blocksToDownload: [CourseBlock] = [] + var sequentialsToDownload: [CourseSequential] = [] + + for chapter in course.childs { + for sequential in chapter.childs where sequential.isDownloadable { + let blocks = downloadableBlocks(from: sequential) + let notDownloadedBlocks = blocks.filter { !isBlockDownloaded($0) } + if !notDownloadedBlocks.isEmpty { + var updatedSequential = sequential + updatedSequential.childs = updatedSequential.childs.map { vertical in + var updatedVertical = vertical + updatedVertical.childs = vertical.childs.filter { block in + notDownloadedBlocks.contains { $0.id == block.id } + } + return updatedVertical + } + blocksToDownload.append(contentsOf: notDownloadedBlocks) + sequentialsToDownload.append(updatedSequential) + } + } + } + + if !blocksToDownload.isEmpty { + let totalFileSize = blocksToDownload.reduce(0) { $0 + ($1.fileSize ?? 0) } + + if !connectivity.isInternetAvaliable { + presentNoInternetAlert(sequentials: sequentialsToDownload) + } else if connectivity.isMobileData { + if storage.userSettings?.wifiOnly == true { + presentWifiRequiredAlert(sequentials: sequentialsToDownload) + } else { + await presentConfirmDownloadCellularAlert( + blocks: blocksToDownload, + sequentials: sequentialsToDownload, + totalFileSize: totalFileSize, + action: { [weak self] in + guard let self else { return } + self.downloadAllButtonState = .cancel + } + ) + } + } else { + if totalFileSize > 100 * 1024 * 1024 { + await presentConfirmDownloadAlert( + blocks: blocksToDownload, + sequentials: sequentialsToDownload, + totalFileSize: totalFileSize, + action: { [weak self] in + guard let self else { return } + self.downloadAllButtonState = .cancel + } + ) + } else { + try? await self.manager.addToDownloadQueue(blocks: blocksToDownload) + self.downloadAllButtonState = .cancel + } + } + } + } + + @MainActor + func filterNotDownloadedBlocks(_ blocks: [CourseBlock]) -> [CourseBlock] { + return blocks.filter { block in + let fileUrl = manager.fileUrl(for: block.id) + return fileUrl == nil + } + } + + @MainActor + func isBlockDownloaded(_ block: CourseBlock) -> Bool { + courseDownloadTasks.contains { $0.blockId == block.id && $0.state == .finished } + } + + @MainActor + func stopAllDownloads() async { + do { + try await manager.cancelAllDownloading() + await setDownloadsStates() + await getDownloadingProgress() + } catch { + errorMessage = CoreLocalization.Error.unknownError + } + } + @MainActor func downloadableBlocks(from sequential: CourseSequential) -> [CourseBlock] { let verticals = sequential.childs @@ -527,7 +786,74 @@ public class CourseContainerViewModel: BaseCourseViewModel { .filter { $0.isDownloadable } return blocks } - + + @MainActor + func getDownloadingProgress() async { + guard let course = courseStructure else { return } + + var totalFilesSize: Int = 0 + var downloadedFilesSize: Int = 0 + var sequentials: [CourseSequential] = [] + + var updatedBlocks: [CourseBlock] = [] + for chapter in course.childs { + for sequential in chapter.childs { + sequentials.append(sequential) + for vertical in sequential.childs { + for block in vertical.childs { + let updatedBlock = await updateFileSizeIfNeeded(for: block) + updatedBlocks.append(updatedBlock) + } + } + } + } + + for block in updatedBlocks { + if let fileSize = block.fileSize { + totalFilesSize += fileSize + } + } + + if connectivity.isInternetAvaliable { + let updatedSequentials = manager.updateUnzippedFileSize(for: sequentials) + realDownloadedFilesSize = updatedSequentials.flatMap { + $0.childs.flatMap { $0.childs.compactMap { $0.actualFileSize } } + }.reduce(0, { $0 + $1 }) + } + + for task in courseDownloadTasks where task.state == .finished { + if let fileUrl = manager.fileUrl(for: task.blockId), + let fileSize = getFileSize(at: fileUrl), + task.type == .video { + if fileSize > 0 { + downloadedFilesSize += fileSize + } + } else { + downloadedFilesSize += task.fileSize + } + } + + withAnimation(.linear(duration: 0.3)) { + self.downloadedFilesSize = downloadedFilesSize + } + withAnimation(.linear(duration: 0.3)) { + self.totalFilesSize = totalFilesSize + } + await fetchLargestDownloadBlocks() + } + + private func getFileSize(at url: URL) -> Int? { + do { + let fileAttributes = try FileManager.default.attributesOfItem(atPath: url.path) + if let fileSize = fileAttributes[.size] as? Int, fileSize > 0 { + return fileSize + } + } catch { + debugLog("Error getting file size: \(error.localizedDescription)") + } + return nil + } + @MainActor func setDownloadsStates() async { guard let course = courseStructure else { return } @@ -540,7 +866,19 @@ 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.blockId == block.id }) { + if var download = courseDownloadTasks.first(where: { $0.blockId == block.id }) { + if let newDateOfLastModified = block.offlineDownload?.lastModified, + let oldDateOfLastModified = download.lastModified { + if Date(iso8601: newDateOfLastModified) > Date(iso8601: oldDateOfLastModified) { + guard isEnoughSpace(for: block.fileSize ?? 0) else { return } + download.lastModified = newDateOfLastModified + try? await manager.cancelDownloading(task: download) + sequentialsChilds.append(.available) + verticalsChilds.append(.available) + try? await self.manager.addToDownloadQueue(blocks: [block]) + continue + } + } switch download.state { case .waiting, .inProgress: sequentialsChilds.append(.downloading) @@ -570,6 +908,13 @@ public class CourseContainerViewModel: BaseCourseViewModel { sequentialsStates[sequential.id] = .available } } + let allStates = sequentialsStates.values + if allStates.contains(.downloading) { + downloadAllButtonState = .cancel + } else { + downloadAllButtonState = .start + } + self.sequentialsDownloadState = sequentialsStates } } @@ -594,7 +939,140 @@ public class CourseContainerViewModel: BaseCourseViewModel { } return nil } - + + private func isEnoughSpace(for fileSize: Int) -> Bool { + if let freeSpace = getFreeDiskSpace() { + return freeSpace > Int(Double(fileSize) * 1.2) + } + return false + } + + private func getFreeDiskSpace() -> Int? { + do { + let attributes = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory() as String) + if let freeSpace = attributes[.systemFreeSize] as? Int64 { + return Int(freeSpace) + } + } catch { + print("Error retrieving free disk space: \(error.localizedDescription)") + } + return nil + } + + private func getUsedDiskSpace() -> Int? { + do { + let attributes = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory() as String) + if let totalSpace = attributes[.systemSize] as? Int64, + let freeSpace = attributes[.systemFreeSize] as? Int64 { + return Int(totalSpace - freeSpace) + } + } catch { + print("Error retrieving used disk space: \(error.localizedDescription)") + } + return nil + } + + // MARK: Larges Downloads + + @MainActor + func fetchLargestDownloadBlocks() async { + let allBlocks = courseStructure?.childs.flatMap { $0.childs.flatMap { $0.childs.flatMap { $0.childs } } } ?? [] + let downloadedBlocks = allBlocks.filter { block in + if let task = courseDownloadTasks.first(where: { $0.blockId == block.id }) { + return task.state == .finished + } + return false + } + + var updatedDownloadedBlocks: [CourseBlock] = [] + + for block in downloadedBlocks { + let updatedBlock = await updateFileSizeIfNeeded(for: block) + updatedDownloadedBlocks.append(updatedBlock) + } + + let filteredBlocks = Array( + updatedDownloadedBlocks + .filter { $0.fileSize != nil } + .sorted { $0.fileSize! > $1.fileSize! } + .prefix(5) + ) + + withAnimation(.linear(duration: 0.3)) { + largestDownloadBlocks = filteredBlocks + } + } + + @MainActor + func updateFileSizeIfNeeded(for block: CourseBlock) async -> CourseBlock { + var updatedBlock = block + if let fileUrl = manager.fileUrl(for: block.id), + let fileSize = getFileSize(at: fileUrl), fileSize > 0, + block.type == .video { + updatedBlock.actualFileSize = fileSize + } + return updatedBlock + } + + @MainActor + func removeBlock(_ block: CourseBlock) async { + router.presentView( + transitionStyle: .coverVertical, + view: DownloadActionView( + actionType: .remove, + courseBlocks: [block], + action: { [weak self] in + guard let self else { return } + withAnimation(.linear(duration: 0.3)) { + self.largestDownloadBlocks.removeAll { $0.id == block.id } + } + Task { + await self.manager.deleteFile(blocks: [block]) + } + self.router.dismiss(animated: true) + }, + cancel: { [weak self] in + guard let self else { return } + self.router.dismiss(animated: true) + } + ), + completion: {} + ) + } + + @MainActor + func removeAllBlocks() async { + let allBlocks = courseStructure?.childs.flatMap { $0.childs.flatMap { $0.childs.flatMap { $0.childs } } } ?? [] + let blocksToRemove = allBlocks.filter { block in + if let task = courseDownloadTasks.first(where: { $0.blockId == block.id }) { + return task.state == .finished + } + return false + } + + router.presentView( + transitionStyle: .coverVertical, + view: DownloadActionView( + actionType: .remove, + courseBlocks: blocksToRemove, + courseName: courseStructure?.displayName ?? "", + action: { [weak self] in + guard let self else { return } + Task { + await self.stopAllDownloads() + await self.manager.deleteFile(blocks: blocksToRemove) + } + self.router.dismiss(animated: true) + }, + cancel: { [weak self] in + guard let self else { return } + self.router.dismiss(animated: true) + } + ), + completion: {} + ) + } + private func addObservers() { manager.eventPublisher() .sink { [weak self] state in @@ -603,16 +1081,17 @@ public class CourseContainerViewModel: BaseCourseViewModel { Task(priority: .background) { debugLog(state, "--- state ---") await self.setDownloadsStates() + await self.getDownloadingProgress() } } .store(in: &cancellables) - + connectivity.internetReachableSubject .sink { [weak self] _ in - guard let self else { return } + guard let self else { return } self.isInternetAvaliable = self.connectivity.isInternetAvaliable - } - .store(in: &cancellables) + } + .store(in: &cancellables) NotificationCenter.default.addObserver( self, @@ -621,11 +1100,17 @@ public class CourseContainerViewModel: BaseCourseViewModel { ) completionPublisher - .sink { [weak self] _ in - guard let self = self else { return } - updateCourseProgress = true - } - .store(in: &cancellables) + .sink { [weak self] _ in + guard let self = self else { return } + updateCourseProgress = true + } + .store(in: &cancellables) + + $sequentialsDownloadState.sink(receiveValue: { states in + if states.values.allSatisfy({ $0 == .available }) { + self.downloadAllButtonState = .start + } + }).store(in: &cancellables) } deinit { @@ -660,8 +1145,8 @@ extension CourseContainerViewModel { struct VerticalsDownloadState: Hashable { let vertical: CourseVertical let state: DownloadViewState - + var downloadableBlocks: [CourseBlock] { - vertical.childs.filter { $0.isDownloadable } + vertical.childs.filter { $0.isDownloadable && $0.type == .video } } } diff --git a/Course/Course/Presentation/CourseAnalytics.swift b/Course/Course/Presentation/CourseAnalytics.swift index 612f9249c..ea794754d 100644 --- a/Course/Course/Presentation/CourseAnalytics.swift +++ b/Course/Course/Presentation/CourseAnalytics.swift @@ -58,6 +58,7 @@ public protocol CourseAnalytics { func finishVerticalBackToOutlineClicked(courseId: String, courseName: String) func courseOutlineCourseTabClicked(courseId: String, courseName: String) func courseOutlineVideosTabClicked(courseId: String, courseName: String) + func courseOutlineOfflineTabClicked(courseId: String, courseName: String) func courseOutlineDatesTabClicked(courseId: String, courseName: String) func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) func courseOutlineHandoutsTabClicked(courseId: String, courseName: String) @@ -137,6 +138,7 @@ class CourseAnalyticsMock: CourseAnalytics { public func finishVerticalBackToOutlineClicked(courseId: String, courseName: String) {} public func courseOutlineCourseTabClicked(courseId: String, courseName: String) {} public func courseOutlineVideosTabClicked(courseId: String, courseName: String) {} + public func courseOutlineOfflineTabClicked(courseId: String, courseName: String) {} public func courseOutlineDatesTabClicked(courseId: String, courseName: String) {} public func courseOutlineDiscussionTabClicked(courseId: String, courseName: String) {} public func courseOutlineHandoutsTabClicked(courseId: String, courseName: String) {} diff --git a/Course/Course/Presentation/Offline/OfflineView.swift b/Course/Course/Presentation/Offline/OfflineView.swift new file mode 100644 index 000000000..02cf0ee3c --- /dev/null +++ b/Course/Course/Presentation/Offline/OfflineView.swift @@ -0,0 +1,268 @@ +// +// OfflineView.swift +// Course +// +// Created by  Stepanok Ivan on 17.06.2024. +// + +import SwiftUI +import Core +import Theme + +struct OfflineView: View { + + enum DownloadAllState: Equatable { + case start + case cancel + + var color: Color { + switch self { + case .start: + Theme.Colors.accentColor + case .cancel: + Theme.Colors.snackbarErrorColor + } + } + + var image: Image { + switch self { + case .start: + CoreAssets.startDownloading.swiftUIImage + case .cancel: + CoreAssets.stopDownloading.swiftUIImage + } + } + + var title: String { + switch self { + case .start: + CourseLocalization.Course.Offline.downloadAll + case .cancel: + CourseLocalization.Course.Offline.cancelCourseDownload + } + } + + var textColor: Color { + switch self { + case .start: + Theme.Colors.white + case .cancel: + Theme.Colors.snackbarErrorColor + } + } + } + + private let courseID: String + @Binding private var coordinate: CGFloat + @Binding private var collapsed: Bool + + @StateObject + private var viewModel: CourseContainerViewModel + + public init( + courseID: String, + coordinate: Binding, + collapsed: Binding, + viewModel: CourseContainerViewModel + ) { + self.courseID = courseID + self._coordinate = coordinate + self._collapsed = collapsed + self._viewModel = StateObject(wrappedValue: { viewModel }()) + } + + public var body: some View { + GeometryReader { proxy in + ZStack(alignment: .center) { + VStack(alignment: .center) { + + // MARK: - Page Body + if viewModel.isShowProgress { + HStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 200) + .padding(.horizontal) + } + } else { + ScrollView { + VStack(alignment: .leading) { + DynamicOffsetView( + coordinate: $coordinate, + collapsed: $collapsed + ) + TotalDownloadedProgressView( + downloadedFilesSize: viewModel.downloadedFilesSize, + totalFilesSize: viewModel.totalFilesSize, + isDownloading: Binding( + get: { viewModel.downloadAllButtonState == .cancel }, + set: { newValue in + viewModel.downloadAllButtonState = newValue ? .cancel : .start + } + ) + ) + .padding(.top, 36) + + if viewModel.downloadedFilesSize == 0 && viewModel.totalFilesSize != 0 { + Text(CourseLocalization.Course.Offline.youCanDownload) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + .padding(.top, 8) + .padding(.bottom, 16) + } else if viewModel.downloadedFilesSize == 0 && viewModel.totalFilesSize == 0 { + Text(CourseLocalization.Course.Offline.youCantDownload) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + .padding(.top, 8) + .padding(.bottom, 16) + } + downloadAll + + if !viewModel.largestDownloadBlocks.isEmpty { + LargestDownloadsView(viewModel: viewModel) + } + removeAllDownloads + + }.padding(.horizontal, 32) + Spacer(minLength: 84) + } + } + } + .frameLimit(width: proxy.size.width) + + // MARK: - Offline mode SnackBar + OfflineSnackBarView( + connectivity: viewModel.connectivity, + reloadAction: {} + ) + + // MARK: - Error Alert + if viewModel.showError { + VStack { + Spacer() + SnackBarView(message: viewModel.errorMessage) + } + .padding(.bottom, viewModel.connectivity.isInternetAvaliable + ? 0 : OfflineSnackBarView.height) + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil + } + } + } + } + .background( + Theme.Colors.background + .ignoresSafeArea() + ) + } + } + + @ViewBuilder + private var downloadAll: some View { + if viewModel.connectivity.isInternetAvaliable + && ((viewModel.totalFilesSize - viewModel.downloadedFilesSize != 0) + || (viewModel.totalFilesSize == 0 && viewModel.downloadedFilesSize == 0)) { + Button(action: { + Task(priority: .low) { + switch viewModel.downloadAllButtonState { + case .start: + await viewModel.downloadAll() + case .cancel: + viewModel.downloadAllButtonState = .start + await viewModel.stopAllDownloads() + } + } + }) { + HStack { + viewModel.downloadAllButtonState.image + .renderingMode(.template) + Text(viewModel.downloadAllButtonState.title) + .font(Theme.Fonts.bodyMedium) + } + .foregroundStyle( + viewModel.totalFilesSize == 0 + ? Theme.Colors.disabledButtonText + : viewModel.downloadAllButtonState.textColor + ) + .frame(maxWidth: .infinity) + .frame(height: 42) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke( + viewModel.totalFilesSize == 0 + ? .clear + : viewModel.downloadAllButtonState.color, + lineWidth: 2 + ) + ) + .background( + viewModel.totalFilesSize == 0 + ? Theme.Colors.disabledButton + : viewModel.downloadAllButtonState == .start ? viewModel.downloadAllButtonState.color : .clear + ) + .cornerRadius(8) + } + } + } + + @ViewBuilder + private var removeAllDownloads: some View { + if viewModel.downloadAllButtonState == .start && !viewModel.largestDownloadBlocks.isEmpty { + VStack(spacing: 16) { + Button(action: { + Task { + await viewModel.removeAllBlocks() + } + }) { + HStack { + CoreAssets.remove.swiftUIImage + Text(CourseLocalization.Course.LargestDownloads.removeDownloads) + .font(Theme.Fonts.bodyMedium) + } + .foregroundStyle(Theme.Colors.snackbarErrorColor) + .frame(maxWidth: .infinity) + .frame(height: 42) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.snackbarErrorColor, lineWidth: 2) + ) + .background(Theme.Colors.background) + .cornerRadius(8) + } + } + .padding(.vertical, 4) + } + } +} + +#if DEBUG +#Preview { + let vm = CourseContainerViewModel( + interactor: CourseInteractor.mock, + authInteractor: AuthInteractor.mock, + router: CourseRouterMock(), + analytics: CourseAnalyticsMock(), + config: ConfigMock(), + connectivity: Connectivity(), + manager: DownloadManagerMock(), + storage: CourseStorageMock(), + isActive: true, + courseStart: nil, + courseEnd: nil, + enrollmentStart: nil, + enrollmentEnd: nil, + lastVisitedBlockID: nil, + coreAnalytics: CoreAnalyticsMock() + ) + + return OfflineView( + courseID: "123", + coordinate: .constant(0), + collapsed: .constant(false), + viewModel: vm + ).onAppear { + vm.isShowProgress = false + } +} +#endif diff --git a/Course/Course/Presentation/Offline/Subviews/LargestDownloadsView.swift b/Course/Course/Presentation/Offline/Subviews/LargestDownloadsView.swift new file mode 100644 index 000000000..6099d7e38 --- /dev/null +++ b/Course/Course/Presentation/Offline/Subviews/LargestDownloadsView.swift @@ -0,0 +1,178 @@ +// +// LargestDownloadsView.swift +// Course +// +// Created by  Stepanok Ivan on 18.06.2024. +// + +import SwiftUI +import Core +import Theme + +public struct LargestDownloadsView: View { + + @State private var isEditing = false + @ObservedObject + private var viewModel: CourseContainerViewModel + + init(viewModel: CourseContainerViewModel) { + self.viewModel = viewModel + } + + public var body: some View { + VStack(alignment: .leading) { + HStack { + Text(CourseLocalization.Course.LargestDownloads.title) + .font(Theme.Fonts.titleMedium) + .foregroundColor(Theme.Colors.textPrimary) + Spacer() + if viewModel.downloadAllButtonState == .start { + Button(action: { + isEditing.toggle() + }) { + Text( + isEditing + ? CourseLocalization.Course.LargestDownloads.done + : CourseLocalization.Course.LargestDownloads.edit + ) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.accentColor) + } + } + } + .padding(.vertical) + + ForEach(viewModel.largestDownloadBlocks) { block in + HStack { + block.type.image + VStack(alignment: .leading) { + Text(block.displayName) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + if let fileSize = block.fileSize { + Text(fileSize.formattedFileSize()) + .font(Theme.Fonts.labelSmall) + .foregroundColor(Theme.Colors.textSecondary) + } + } + Spacer() + if isEditing { + Button(action: { + Task { + await viewModel.removeBlock(block) + } + }) { + CoreAssets.remove.swiftUIImage + .foregroundColor(Theme.Colors.alert) + } + } else { + CoreAssets.deleteDownloading.swiftUIImage + .foregroundColor(.green) + } + } + Divider() + .foregroundStyle(Theme.Colors.shade) + .padding(.vertical, 8) + } + } + .onChange(of: viewModel.downloadAllButtonState, perform: { state in + if state == .cancel { + self.isEditing = false + } + }) + .onAppear { + Task { + await viewModel.fetchLargestDownloadBlocks() + } + } + } +} + +#if DEBUG +struct LargestDownloadsView_Previews: PreviewProvider { + static var previews: some View { + + let vm = CourseContainerViewModel( + interactor: CourseInteractor.mock, + authInteractor: AuthInteractor.mock, + router: CourseRouterMock(), + analytics: CourseAnalyticsMock(), + config: ConfigMock(), + connectivity: Connectivity(), + manager: DownloadManagerMock(), + storage: CourseStorageMock(), + isActive: true, + courseStart: nil, + courseEnd: nil, + enrollmentStart: nil, + enrollmentEnd: nil, + lastVisitedBlockID: nil, + coreAnalytics: CoreAnalyticsMock() + ) + + LargestDownloadsView(viewModel: vm) + .loadFonts() + .onAppear { + vm.largestDownloadBlocks = [ + CourseBlock( + blockId: "", + id: "1", + courseId: "", + graded: false, + due: nil, + completion: 0, + type: .discussion, + displayName: "Welcome to Mobile Testing", + studentUrl: "", + webUrl: "", + encodedVideo: nil, + multiDevice: nil, + offlineDownload: OfflineDownload( + fileUrl: "123", + lastModified: "e", + fileSize: 3423123214 + ) + ), + CourseBlock( + blockId: "", + id: "2", + courseId: "", + graded: false, + due: nil, + completion: 0, + type: .video, + displayName: "Advanced Mobile Sketching", + studentUrl: "", + webUrl: "", + encodedVideo: nil, + multiDevice: nil, + offlineDownload: OfflineDownload( + fileUrl: "123", + lastModified: "e", + fileSize: 34213214 + ) + ), + CourseBlock( + blockId: "", + id: "3", + courseId: "", + graded: false, + due: nil, + completion: 0, + type: .problem, + displayName: "File Naming Conventions", + studentUrl: "", + webUrl: "", + encodedVideo: nil, + multiDevice: nil, + offlineDownload: OfflineDownload( + fileUrl: "123", + lastModified: "e", + fileSize: 742343214 + ) + ) + ] + } + } +} +#endif diff --git a/Course/Course/Presentation/Offline/Subviews/TotalDownloadedProgressView.swift b/Course/Course/Presentation/Offline/Subviews/TotalDownloadedProgressView.swift new file mode 100644 index 000000000..59bcd40b8 --- /dev/null +++ b/Course/Course/Presentation/Offline/Subviews/TotalDownloadedProgressView.swift @@ -0,0 +1,105 @@ +// +// TotalDownloadedProgressView.swift +// Course +// +// Created by  Stepanok Ivan on 17.06.2024. +// + +import SwiftUI +import Theme +import Core + +public struct TotalDownloadedProgressView: View { + + private let downloadedFilesSize: Int + private let readyToDownload: Int + private let totalFilesSize: Int + @Binding var isDownloading: Bool + + public init(downloadedFilesSize: Int, totalFilesSize: Int, isDownloading: Binding) { + self.downloadedFilesSize = downloadedFilesSize + self.totalFilesSize = totalFilesSize + self.readyToDownload = totalFilesSize - downloadedFilesSize + self._isDownloading = isDownloading + } + + public var body: some View { + VStack(alignment: .center, spacing: 6) { + HStack { + Text(downloadedFilesSize.formattedFileSize()) + .foregroundStyle( + totalFilesSize == 0 + ? Theme.Colors.textSecondaryLight + : Theme.Colors.success + ) + Spacer() + if totalFilesSize != 0 { + Text(readyToDownload.formattedFileSize()) + } + } + .font(Theme.Fonts.titleLarge) + HStack { + CoreAssets.deleteDownloading.swiftUIImage.renderingMode(.template) + .foregroundStyle( + totalFilesSize == 0 + ? Theme.Colors.textSecondaryLight + : Theme.Colors.success + ) + Text(totalFilesSize == 0 + ? CourseLocalization.Course.TotalProgress.avaliableToDownload + : CourseLocalization.Course.TotalProgress.downloaded) + .foregroundStyle( + totalFilesSize == 0 + ? Theme.Colors.textSecondaryLight + : Theme.Colors.success + ) + Spacer() + if totalFilesSize != 0 { + CoreAssets.startDownloading.swiftUIImage + Text(isDownloading ? + CourseLocalization.Course.TotalProgress.downloading + : CourseLocalization.Course.TotalProgress.readyToDownload) + } + } + .font(Theme.Fonts.labelLarge) + .padding(.bottom, 10) + if totalFilesSize != 0 { + ZStack(alignment: .leading) { + GeometryReader { geometry in + RoundedRectangle(cornerRadius: 2.5) + .fill(Theme.Colors.textSecondary.opacity(0.5)) + .frame(width: geometry.size.width, height: 5) + + RoundedCorners(tl: 2.5, tr: 0, bl: 2.5, br: 0) + .fill(Theme.Colors.success) + .frame( + width: geometry.size.width * CGFloat( + downloadedFilesSize + ) / CGFloat(totalFilesSize), + height: 5 + ) + } + .frame(height: 5) + } + .cornerRadius(5) + .padding(.bottom, 10) + } + } + .onChange(of: readyToDownload, perform: { size in + if size == 0 { + self.isDownloading = false + } + }) + } +} + +#if DEBUG +#Preview { + TotalDownloadedProgressView( + downloadedFilesSize: 24341324514, + totalFilesSize: 324324132413, + isDownloading: .constant(false) + ) + .loadFonts() +} +#endif diff --git a/Course/Course/Presentation/Outline/ContinueWithView.swift b/Course/Course/Presentation/Outline/ContinueWithView.swift index 45271c4ae..0380e51d7 100644 --- a/Course/Course/Presentation/Outline/ContinueWithView.swift +++ b/Course/Course/Presentation/Outline/ContinueWithView.swift @@ -90,7 +90,8 @@ struct ContinueWithView_Previews: PreviewProvider { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ), CourseBlock( blockId: "2", @@ -104,7 +105,8 @@ struct ContinueWithView_Previews: PreviewProvider { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: false + multiDevice: false, + offlineDownload: nil ) ] diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index bb51fa74a..7cd3a1f28 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -101,6 +101,7 @@ public struct CourseOutlineView: View { // MARK: - Sections CustomDisclosureGroup( + isVideo: isVideo, course: course, proxy: proxy, viewModel: viewModel @@ -256,7 +257,8 @@ public struct CourseOutlineView: View { content: { WebBrowser( url: url, - pageTitle: CourseLocalization.Outline.certificate + pageTitle: CourseLocalization.Outline.certificate, + connectivity: viewModel.connectivity ) } ) diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift index bc3722da1..523c04e0a 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalImageView.swift @@ -42,7 +42,8 @@ struct CourseVerticalImageView_Previews: PreviewProvider { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ) ] @@ -60,7 +61,8 @@ struct CourseVerticalImageView_Previews: PreviewProvider { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: false + multiDevice: false, + offlineDownload: nil ) ] let blocks3 = [ @@ -77,7 +79,8 @@ struct CourseVerticalImageView_Previews: PreviewProvider { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ) ] let blocks4 = [ @@ -94,7 +97,8 @@ struct CourseVerticalImageView_Previews: PreviewProvider { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: false + multiDevice: false, + offlineDownload: nil ) ] let blocks5 = [ @@ -111,7 +115,8 @@ struct CourseVerticalImageView_Previews: PreviewProvider { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ) ] HStack { diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift index cd0c8d174..1614e075d 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift @@ -82,48 +82,6 @@ public struct CourseVerticalView: View { }).accessibilityElement(children: .ignore) .accessibilityLabel(vertical.displayName) Spacer() - if let state = viewModel.downloadState[vertical.id] { - switch state { - case .available: - DownloadAvailableView() - .accessibilityElement(children: .ignore) - .accessibilityLabel(CourseLocalization.Accessibility.download) - .onTapGesture { - Task { - await viewModel.onDownloadViewTap( - blockId: vertical.id, - state: state - ) - } - - } - case .downloading: - DownloadProgressView() - .accessibilityElement(children: .ignore) - .accessibilityLabel(CourseLocalization.Accessibility.cancelDownload) - .onTapGesture { - Task { - await viewModel.onDownloadViewTap( - blockId: vertical.id, - state: state - ) - } - - } - case .finished: - DownloadFinishedView() - .accessibilityElement(children: .ignore) - .accessibilityLabel(CourseLocalization.Accessibility.deleteDownload) - .onTapGesture { - Task { - await viewModel.onDownloadViewTap( - blockId: vertical.id, - state: state - ) - } - } - } - } Image(systemName: "chevron.right") .flipsForRightToLeftLayoutDirection(true) .padding(.vertical, 8) diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift index 5247ca700..e15cc024d 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalViewModel.swift @@ -83,19 +83,6 @@ public class CourseVerticalViewModel: BaseCourseViewModel { } } } - - func trackVerticalClicked( - courseId: String, - courseName: String, - vertical: CourseVertical - ) { - analytics.verticalClicked( - courseId: courseId, - courseName: courseName, - blockId: vertical.blockId, - blockName: vertical.displayName - ) - } @MainActor private func setDownloadsStates() async { @@ -126,4 +113,17 @@ public class CourseVerticalViewModel: BaseCourseViewModel { } downloadState = states } + + func trackVerticalClicked( + courseId: String, + courseName: String, + vertical: CourseVertical + ) { + analytics.verticalClicked( + courseId: courseId, + courseName: courseName, + blockId: vertical.blockId, + blockName: vertical.displayName + ) + } } diff --git a/Course/Course/Presentation/Subviews/ActionViews/DeviceStorageFullAlertView.swift b/Course/Course/Presentation/Subviews/ActionViews/DeviceStorageFullAlertView.swift new file mode 100644 index 000000000..4e440283c --- /dev/null +++ b/Course/Course/Presentation/Subviews/ActionViews/DeviceStorageFullAlertView.swift @@ -0,0 +1,235 @@ +// +// DeviceStorageFullAlertView.swift +// Course +// +// Created by  Stepanok Ivan on 13.06.2024. +// + +import SwiftUI +import Core +import Theme + +public struct DeviceStorageFullAlertView: View { + private let sequentials: [CourseSequential] + private let usedSpace: Int + private let freeSpace: Int + private let close: () -> Void + @State private var fadeEffect: Bool = false + + @Environment(\.isHorizontal) private var isHorizontal + + init( + sequentials: [CourseSequential], + usedSpace: Int, + freeSpace: Int, + close: @escaping () -> Void + ) { + self.sequentials = sequentials + self.usedSpace = usedSpace + self.freeSpace = freeSpace + self.close = close + } + + public var body: some View { + ZStack(alignment: .bottom) { + Color.black.opacity(fadeEffect ? 0.15 : 0) + .onTapGesture { + close() + fadeEffect = false + } + content + .padding(.bottom, 20) + } + .ignoresSafeArea() + .onAppear { + withAnimation(Animation.linear(duration: 0.3).delay(0.2)) { + fadeEffect = true + } + } + } + + private var content: some View { + VStack { + HStack { + CoreAssets.reportOctagon.swiftUIImage + .scaledToFit() + .foregroundStyle(Theme.Colors.alert) + Text(CourseLocalization.Course.StorageAlert.title) + .font(Theme.Fonts.titleLarge) + } + .padding(.top, 16) + .padding(.horizontal, 16) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + + if sequentials.count <= 3 { + list + } else { + ScrollView { + list + } + .frame(maxHeight: isHorizontal ? 80 : 200) + } + + VStack(spacing: 4) { + StorageProgressBar( + usedSpace: usedSpace, + contentSize: totalSize + ) + .padding(.horizontal, 16) + .padding(.top, 8) + + HStack { + Text( + CourseLocalization.Course.StorageAlert.usedAndFree( + usedSpace.formattedFileSize(), + freeSpace.formattedFileSize() + ) + ) + .foregroundColor(Theme.Colors.textSecondaryLight) + .font(Theme.Fonts.bodySmall) + Spacer() + Text(totalSize.formattedFileSize()) + .foregroundColor(Theme.Colors.alert) + .font(Theme.Fonts.bodySmall) + } + .padding(.horizontal, 16) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + + Text(CourseLocalization.Course.StorageAlert.description) + .font(.subheadline) + .padding(.horizontal, 16) + .padding(.top, 8) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + + VStack(spacing: 16) { + Button(action: { + fadeEffect = false + close() + }) { + Text(CourseLocalization.Course.Alert.close) + .font(Theme.Fonts.bodyMedium) + .foregroundStyle(Theme.Colors.accentColor) + .frame(maxWidth: .infinity) + .frame(height: 42) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.accentColor, lineWidth: 2) + ) + .background(Theme.Colors.background) + .cornerRadius(8) + + } + } + .padding(16) + } + .background(Theme.Colors.background) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.datesSectionStroke, lineWidth: 2) + ) + .cornerRadius(8) + .padding(16) + .frame(maxWidth: 400) + } + + @ViewBuilder + var list: some View { + VStack(spacing: 8) { + ForEach(sequentials) { sequential in + HStack { + sequential.type.image + .renderingMode(.template) + .foregroundStyle(Theme.Colors.textPrimary) + Text(sequential.displayName) + .font(Theme.Fonts.bodyMedium) + .lineLimit(1) + if sequential.totalSize != 0 { + Text(sequential.totalSize.formattedFileSize()) + .foregroundColor(Theme.Colors.textSecondaryLight) + .font(Theme.Fonts.bodySmall) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + } + } + + private var totalSize: Int { + sequentials.reduce(0) { $0 + $1.totalSize } + } +} + +struct StorageProgressBar: View { + let usedSpace: Int + let contentSize: Int + + var body: some View { + GeometryReader { geometry in + let totalSpace = geometry.size.width + let usedSpace = Double(usedSpace) + let contentSize = Double(contentSize) + let total = usedSpace + contentSize + + let minSize: Double = 0.1 + let usedSpacePercentage = (usedSpace / total) + minSize + let contentSizePercentage = (contentSize / total) + minSize + let normalizationFactor = 1 / (usedSpacePercentage + contentSizePercentage) + + let normalizedUsedSpaceWidth = usedSpacePercentage * normalizationFactor + + ZStack { + RoundedRectangle(cornerRadius: 3) + .fill(Theme.Colors.datesSectionStroke) + .frame(width: totalSpace, height: 42) + + RoundedRectangle(cornerRadius: 2) + .fill(Theme.Colors.background) + .frame(width: totalSpace - 4, height: 38) + + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2) + .fill(Theme.Colors.alert) + .frame(width: totalSpace - 6, height: 36) + + HStack(spacing: 0) { + RoundedCorners(tl: 2, bl: 2) + .fill(Theme.Colors.datesSectionStroke) + .frame(width: (totalSpace - 6) * normalizedUsedSpaceWidth, height: 36) + Rectangle() + .fill(Theme.Colors.background) + .frame(width: 1, height: 36) + } + } + } + } + .frame(height: 44) + } +} + +#if DEBUG +struct DeviceStorageFullAlertView_Previews: PreviewProvider { + static var previews: some View { + DeviceStorageFullAlertView( + sequentials: [ + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ) + ], + usedSpace: 460580220928, + freeSpace: 33972756480, + close: { print("Close action triggered") } + ) + .loadFonts() + } +} +#endif diff --git a/Course/Course/Presentation/Subviews/ActionViews/DownloadActionView.swift b/Course/Course/Presentation/Subviews/ActionViews/DownloadActionView.swift new file mode 100644 index 000000000..1dbbce63c --- /dev/null +++ b/Course/Course/Presentation/Subviews/ActionViews/DownloadActionView.swift @@ -0,0 +1,326 @@ +// +// DownloadActionView.swift +// Course +// +// Created by  Stepanok Ivan on 12.06.2024. +// + +import SwiftUI +import Core +import Theme + +enum ContentActionType { + case remove + case confirmDownload + case confirmDownloadCellular +} + +struct Lesson: Identifiable { + let id = UUID() + let name: String + let size: Int + let image: Image +} + +public struct DownloadActionView: View { + private let actionType: ContentActionType + private let sequentials: [CourseSequential] + private let courseBlocks: [CourseBlock] + private let courseName: String? + private let action: () -> Void + private let cancel: () -> Void + @State private var fadeEffect: Bool = false + + @Environment(\.isHorizontal) private var isHorizontal + + init( + actionType: ContentActionType, + sequentials: [CourseSequential], + action: @escaping () -> Void, + cancel: @escaping () -> Void + ) { + self.actionType = actionType + self.sequentials = sequentials + self.courseName = nil + self.courseBlocks = [] + self.action = action + self.cancel = cancel + } + + init( + actionType: ContentActionType, + courseBlocks: [CourseBlock], + courseName: String? = nil, + action: @escaping () -> Void, + cancel: @escaping () -> Void + ) { + self.actionType = actionType + self.sequentials = [] + self.courseBlocks = courseBlocks + self.courseName = courseName + self.action = action + self.cancel = cancel + } + + public var body: some View { + ZStack(alignment: .bottom) { + Color.black.opacity(fadeEffect ? 0.15 : 0) + .onTapGesture { + cancel() + fadeEffect = false + } + content + .padding(.bottom, 20) + } + .ignoresSafeArea() + .onAppear { + withAnimation(Animation.linear(duration: 0.3).delay(0.2)) { + fadeEffect = true + } + } + } + + private var content: some View { + VStack { + HStack { + if actionType == .confirmDownloadCellular { + CoreAssets.warningFilled.swiftUIImage + .resizable() + .scaledToFit() + .frame(width: 22) + } + Text(headerTitle) + .font(Theme.Fonts.titleLarge) + } + .padding(.top, 16) + .padding(.horizontal, 16) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + + if let courseName { + HStack { + Image(systemName: "doc.text") + .renderingMode(.template) + .foregroundStyle(Theme.Colors.textPrimary) + Text(courseName) + .font(Theme.Fonts.bodyMedium) + .lineLimit(1) + } + .padding(.horizontal, 16) + .padding(.top, 8) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } else { + if sequentials.count <= 3 { + list + } else { + ScrollView { + list + } + .frame(maxHeight: isHorizontal ? 80 : 200) + } + } + + Text(descriptionText) + .font(.subheadline) + .padding(.horizontal, 16) + .padding(.top, 8) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + + VStack(spacing: 16) { + Button(action: { + fadeEffect = false + action() + }) { + HStack { + actionButtonImage + .renderingMode(.template) + Text(actionButtonText) + .font(Theme.Fonts.bodyMedium) + } + .foregroundStyle(Theme.Colors.white) + .frame(maxWidth: .infinity) + .frame(height: 42) + .background(actionButtonColor) + .cornerRadius(8) + } + + Button(action: { + fadeEffect = false + cancel() + }) { + Text(CourseLocalization.Course.Alert.cancel) + .font(Theme.Fonts.bodyMedium) + .foregroundStyle(Theme.Colors.accentColor) + .frame(maxWidth: .infinity) + .frame(height: 42) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.accentColor, lineWidth: 2) + ) + .background(Theme.Colors.background) + .cornerRadius(8) + + } + } + .padding([.leading, .trailing, .bottom], 16) + } + .background(Theme.Colors.background) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.datesSectionStroke, lineWidth: 2) + ) + .cornerRadius(8) + .padding(16) + .frame(maxWidth: 400) + } + + @ViewBuilder + var list: some View { + VStack(spacing: 8) { + if sequentials.isEmpty { + ForEach(Array(courseBlocks.enumerated()), id: \.offset) { _, block in + HStack { + block.type.image + .renderingMode(.template) + .foregroundStyle(Theme.Colors.textPrimary) + Text(block.displayName) + .font(Theme.Fonts.bodyMedium) + .lineLimit(1) + if let fileSize = block.fileSize, fileSize != 0 { + Text(fileSize.formattedFileSize()) + .foregroundColor(Theme.Colors.textSecondaryLight) + .font(Theme.Fonts.bodySmall) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + } else { + ForEach(sequentials) { sequential in + HStack { + sequential.type.image + .renderingMode(.template) + .foregroundStyle(Theme.Colors.textPrimary) + Text(sequential.displayName) + .font(Theme.Fonts.bodyMedium) + .lineLimit(1) + if sequential.totalSize != 0 { + Text(sequential.totalSize.formattedFileSize()) + .foregroundColor(Theme.Colors.textSecondaryLight) + .font(Theme.Fonts.bodySmall) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + } + } + } + + private var headerTitle: String { + switch actionType { + case .remove: + return CourseLocalization.Course.Alert.removeTitle + case .confirmDownload: + return CourseLocalization.Course.Alert.confirmDownloadTitle + case .confirmDownloadCellular: + return CourseLocalization.Course.Alert.confirmDownloadCellularTitle + } + } + + private var descriptionText: String { + switch actionType { + case .remove: + return CourseLocalization.Course.Alert.removeDescription(totalSize) + case .confirmDownload: + return CourseLocalization.Course.Alert.confirmDownloadDescription(totalSize) + case .confirmDownloadCellular: + return CourseLocalization.Course.Alert.confirmDownloadCellularDescription(totalSize) + } + } + + private var actionButtonText: String { + switch actionType { + case .remove: + return CourseLocalization.Course.Alert.remove + case .confirmDownload, .confirmDownloadCellular: + return CourseLocalization.Course.Alert.download + } + } + + private var actionButtonImage: Image { + switch actionType { + case .remove: + return CoreAssets.remove.swiftUIImage + case .confirmDownload, .confirmDownloadCellular: + return CoreAssets.startDownloading.swiftUIImage + } + } + + private var actionButtonColor: Color { + switch actionType { + case .remove: + Theme.Colors.snackbarErrorColor + case .confirmDownloadCellular, .confirmDownload: + Theme.Colors.accentColor + + } + } + + private var totalSize: String { + if sequentials.isEmpty { + courseBlocks.reduce(0) { $0 + ($1.fileSize ?? 0) }.formattedFileSize() + } else { + sequentials.reduce(0) { $0 + $1.totalSize }.formattedFileSize() + } + } +} + +#if DEBUG +struct ContentActionView_Previews: PreviewProvider { + static var previews: some View { + ScrollView { + DownloadActionView( + actionType: .remove, + sequentials: [ + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ), + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ), + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ) + ], + action: { + print("Action triggered") + }, + cancel: { print("Cancel triggered") } + ) + }.loadFonts() + } +} +#endif diff --git a/Course/Course/Presentation/Subviews/ActionViews/DownloadErrorAlertView.swift b/Course/Course/Presentation/Subviews/ActionViews/DownloadErrorAlertView.swift new file mode 100644 index 000000000..739d5267a --- /dev/null +++ b/Course/Course/Presentation/Subviews/ActionViews/DownloadErrorAlertView.swift @@ -0,0 +1,298 @@ +// +// DownloadErrorAlertView.swift +// Course +// +// Created by  Stepanok Ivan on 13.06.2024. +// + +import SwiftUI +import Core +import Theme + +public enum ContentErrorType { + case downloadFailed + case noInternetConnection + case wifiRequired +} + +public struct DownloadErrorAlertView: View { + + private let errorType: ContentErrorType + private let sequentials: [CourseSequential] + private let tryAgain: () -> Void + private let close: () -> Void + @State private var fadeEffect: Bool = false + + @Environment(\.isHorizontal) private var isHorizontal + + public init( + errorType: ContentErrorType, + sequentials: [CourseSequential], + tryAgain: @escaping () -> Void = {}, + close: @escaping () -> Void + ) { + self.errorType = errorType + self.sequentials = sequentials + self.tryAgain = tryAgain + self.close = close + } + + public var body: some View { + ZStack(alignment: .bottom) { + Color.black.opacity(fadeEffect ? 0.15 : 0) + .onTapGesture { + close() + fadeEffect = false + } + content + .padding(.bottom, 20) + } + .ignoresSafeArea() + .onAppear { + withAnimation(Animation.linear(duration: 0.3).delay(0.2)) { + fadeEffect = true + } + } + } + + private var content: some View { + VStack { + HStack { + CoreAssets.reportOctagon.swiftUIImage + .scaledToFit() + .foregroundStyle(Theme.Colors.alert) + Text(headerTitle) + .font(Theme.Fonts.titleLarge) + } + .padding(.top, 16) + .padding(.horizontal, 16) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + + if sequentials.count <= 3 { + list + } else { + ScrollView { + list + } + .frame(maxHeight: isHorizontal ? 80 : 200) + } + + Text(descriptionText) + .font(.subheadline) + .padding(.horizontal, 16) + .padding(.top, 8) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + + VStack(spacing: 16) { + + if errorType == .downloadFailed { + Button(action: { + fadeEffect = false + tryAgain() + }) { + Text(CourseLocalization.Course.Alert.tryAgain) + .font(Theme.Fonts.bodyMedium) + .foregroundStyle(Theme.Colors.white) + .frame(maxWidth: .infinity) + .frame(height: 42) + .background(Theme.Colors.accentColor) + .cornerRadius(8) + + } + } + + Button(action: { + fadeEffect = false + close() + }) { + Text(CourseLocalization.Course.Alert.close) + .font(Theme.Fonts.bodyMedium) + .foregroundStyle(Theme.Colors.accentColor) + .frame(maxWidth: .infinity) + .frame(height: 42) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.accentColor, lineWidth: 2) + ) + .background(Theme.Colors.background) + .cornerRadius(8) + + } + } + .padding([.leading, .trailing, .bottom], 16) + } + .background(Theme.Colors.background) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Theme.Colors.datesSectionStroke, lineWidth: 2) + ) + .cornerRadius(8) + .padding(16) + .frame(maxWidth: 400) + } + + @ViewBuilder + var list: some View { + VStack(spacing: 8) { + ForEach(sequentials) { sequential in + HStack { + sequential.type.image + .renderingMode(.template) + .foregroundStyle(Theme.Colors.textPrimary) + Text(sequential.displayName) + .font(Theme.Fonts.bodyMedium) + .lineLimit(1) + if sequential.totalSize != 0 { + Text(sequential.totalSize.formattedFileSize()) + .foregroundColor(Theme.Colors.textSecondaryLight) + .font(Theme.Fonts.bodySmall) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .frame(minWidth: 0, maxWidth: .infinity, alignment: .leading) + } + } + } + + private var headerTitle: String { + switch errorType { + case .downloadFailed: + return CourseLocalization.Course.Error.downloadFailedTitle + case .noInternetConnection: + return CourseLocalization.Course.Error.noInternetConnectionTitle + case .wifiRequired: + return CourseLocalization.Course.Error.wifiRequiredTitle + } + } + + private var descriptionText: String { + switch errorType { + case .downloadFailed: + return CourseLocalization.Course.Error.downloadFailedDescription + case .noInternetConnection: + return CourseLocalization.Course.Error.noInternetConnectionDescription + case .wifiRequired: + return CourseLocalization.Course.Error.wifiRequiredDescription + } + } +} + +#if DEBUG +struct DownloadErrorAlertView_Previews: PreviewProvider { + static var previews: some View { + ScrollView { + DownloadErrorAlertView( + errorType: .downloadFailed, + sequentials: [ + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ) + ], + close: { print("Cancel triggered") } + ) + + DownloadErrorAlertView( + errorType: .noInternetConnection, + sequentials: [ + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ), + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ), + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ), + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ), + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ), + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ), + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ) + ], + close: { print("Cancel triggered") } + ) + + DownloadErrorAlertView( + errorType: .wifiRequired, + sequentials: [ + CourseSequential( + blockId: "", + id: "", + displayName: "Course intro", + type: .chapter, + completion: 0, + childs: [], + sequentialProgress: nil, + due: nil + ) + ], + close: { print("Cancel triggered") } + ) + }.loadFonts() + } +} +#endif diff --git a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift index 841c49c1f..b4812db24 100644 --- a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift +++ b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift @@ -43,18 +43,17 @@ final class CourseVideoDownloadBarViewModel: ObservableObject { return 0.0 } guard let index = courseViewModel.courseDownloadTasks.firstIndex( - where: { $0.id == currentDownloadTask.id } + where: { $0.id == currentDownloadTask.id && $0.type == .video } ) else { return 0.0 } courseViewModel.courseDownloadTasks[index].progress = currentDownloadTask.progress - return courseViewModel - .courseDownloadTasks - .reduce(0) { $0 + $1.progress } / Double(courseViewModel.courseDownloadTasks.count) + let videoTasks = courseViewModel.courseDownloadTasks.filter { $0.type == .video } + return videoTasks.reduce(0) { $0 + $1.progress } / Double(videoTasks.count) } var downloadableVerticals: Set { - courseViewModel.downloadableVerticals + courseViewModel.downloadableVerticals.filter { $0.downloadableBlocks.contains { $0.type == .video } } } var allVideosDownloaded: Bool { @@ -180,7 +179,7 @@ final class CourseVideoDownloadBarViewModel: ObservableObject { let blocks = downloadableVerticals.filter { $0.state != .finished }.flatMap { $0.vertical.childs } await courseViewModel.download( state: .available, - blocks: blocks + blocks: blocks.filter { $0.type == .video }, sequentials: [] ) } else { do { @@ -188,7 +187,6 @@ final class CourseVideoDownloadBarViewModel: ObservableObject { } catch { debugLog(error) } - } } diff --git a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift index 22bee864a..e783bdc07 100644 --- a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift +++ b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift @@ -12,12 +12,14 @@ import Theme struct CustomDisclosureGroup: View { @State private var expandedSections: [String: Bool] = [:] + private let isVideo: Bool private let proxy: GeometryProxy private let course: CourseStructure private let viewModel: CourseContainerViewModel private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } - init(course: CourseStructure, proxy: GeometryProxy, viewModel: CourseContainerViewModel) { + init(isVideo: Bool, course: CourseStructure, proxy: GeometryProxy, viewModel: CourseContainerViewModel) { + self.isVideo = isVideo self.course = course self.proxy = proxy self.viewModel = viewModel @@ -46,28 +48,11 @@ struct CustomDisclosureGroup: View { .foregroundColor(Theme.Colors.textPrimary) .lineLimit(1) Spacer() - if canDownloadAllSections(in: chapter), - let state = downloadAllButtonState(for: chapter) { + if canDownloadAllSections(in: chapter, videoOnly: isVideo), + let state = downloadAllButtonState(for: chapter, videoOnly: isVideo) { Button( action: { - switch state { - case .finished: - viewModel.router.presentAlert( - alertTitle: CourseLocalization.Alert.warning, - alertMessage: deleteMessage(for: chapter), - positiveAction: CoreLocalization.Alert.delete, - onCloseTapped: { - viewModel.router.dismiss(animated: true) - }, - okTapped: { - downloadAllSubsections(in: chapter, state: state) - viewModel.router.dismiss(animated: true) - }, - type: .deleteVideo - ) - default: downloadAllSubsections(in: chapter, state: state) - } }, label: { switch state { case .available: @@ -146,7 +131,14 @@ struct CustomDisclosureGroup: View { let numPointsPossible = sequentialProgress.numPointsPossible, let due = sequential.due { let daysRemaining = getAssignmentStatus(for: due) - Text("\(assignmentType) - \(daysRemaining) - \(numPointsEarned) / \(numPointsPossible)") + Text( + """ + \(assignmentType) - + \(daysRemaining) - + \(numPointsEarned) / + \(numPointsPossible) + """ + ) .font(Theme.Fonts.bodySmall) .multilineTextAlignment(.leading) .lineLimit(2) @@ -213,9 +205,15 @@ struct CustomDisclosureGroup: View { } } - private func canDownloadAllSections(in chapter: CourseChapter) -> Bool { + private func canDownloadAllSections(in chapter: CourseChapter, videoOnly: Bool) -> Bool { for sequential in chapter.childs { - if let state = viewModel.sequentialsDownloadState[sequential.id] { + if videoOnly { + let isDownloadable = sequential.childs.flatMap { + $0.childs.filter({ $0.type == .video }) + }.contains(where: { $0.isDownloadable }) + guard isDownloadable else { return false } + } + if viewModel.sequentialsDownloadState[sequential.id] != nil { return true } } @@ -224,18 +222,21 @@ struct CustomDisclosureGroup: View { private func downloadAllSubsections(in chapter: CourseChapter, state: DownloadViewState) { Task { + var allBlocks: [CourseBlock] = [] for sequential in chapter.childs { - await viewModel.onDownloadViewTap( - chapter: chapter, - blockId: sequential.id, - state: state - ) + let blocks = await viewModel.collectBlocks(chapter: chapter, blockId: sequential.id, state: state) + allBlocks.append(contentsOf: blocks) } + await viewModel.download( + state: state, + blocks: allBlocks, + sequentials: chapter.childs.filter({ $0.isDownloadable }) + ) } } - private func downloadAllButtonState(for chapter: CourseChapter) -> DownloadViewState? { - if canDownloadAllSections(in: chapter) { + private func downloadAllButtonState(for chapter: CourseChapter, videoOnly: Bool) -> DownloadViewState? { + if canDownloadAllSections(in: chapter, videoOnly: videoOnly) { let downloads = chapter.childs.filter({ viewModel.sequentialsDownloadState[$0.id] != nil }) if downloads.contains(where: { viewModel.sequentialsDownloadState[$0.id] == .downloading }) { @@ -397,6 +398,7 @@ struct CustomDisclosureGroup_Previews: PreviewProvider { return GeometryReader { proxy in ScrollView { CustomDisclosureGroup( + isVideo: false, course: CourseStructure( id: "Id", graded: false, diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index 0248e2393..f2a50efe1 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -34,6 +34,9 @@ public struct CourseUnitView: View { private let portraitTopSpacing: CGFloat = 60 private let landscapeTopSpacing: CGFloat = 75 + @State private var videoURL: URL? + @State private var webURL: URL? + let isDropdownActive: Bool var sequenceTitle: String { @@ -184,12 +187,14 @@ public struct CourseUnitView: View { isOnScreen: index == viewModel.index ) .frameLimit(width: reader.size.width) - + if !isHorizontal { Spacer(minLength: 150) } } else { - FullScreenErrorView(type: .noInternet) + OfflineContentView( + isDownloadable: false + ) } } else { @@ -214,30 +219,39 @@ public struct CourseUnitView: View { ) .padding(.top, 5) .frameLimit(width: reader.size.width) - + if !isHorizontal { Spacer(minLength: 150) } } else { - FullScreenErrorView(type: .noInternet) + OfflineContentView( + isDownloadable: true + ) } } + // MARK: Web - case let .web(url, injections): + case let .web(url, injections, blockId, isDownloadable): if index >= viewModel.index - 1 && index <= viewModel.index + 1 { - if viewModel.connectivity.isInternetAvaliable { + let localUrl = viewModel.urlForOfflineContent(blockId: blockId)?.absoluteString + if viewModel.connectivity.isInternetAvaliable || localUrl != nil { + // not need to add frame limit there because we did that with injection WebView( url: url, + localUrl: viewModel.connectivity.isInternetAvaliable ? nil : localUrl, injections: injections, + blockID: block.id, roundedBackgroundEnabled: !viewModel.courseUnitProgressEnabled ) - // not need to add frame limit there because we did that with injection } else { - FullScreenErrorView(type: .noInternet) + OfflineContentView( + isDownloadable: isDownloadable + ) } } else { EmptyView() } + // MARK: Unknown case .unknown(let url): if index >= viewModel.index - 1 && index <= viewModel.index + 1 { @@ -247,7 +261,9 @@ public struct CourseUnitView: View { Spacer() .frame(minHeight: 100) } else { - FullScreenErrorView(type: .noInternet) + OfflineContentView( + isDownloadable: false + ) } } else { EmptyView() @@ -449,7 +465,8 @@ struct CourseUnitView_Previews: PreviewProvider { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ), CourseBlock( blockId: "2", @@ -464,7 +481,8 @@ struct CourseUnitView_Previews: PreviewProvider { studentUrl: "2", webUrl: "2", encodedVideo: nil, - multiDevice: false + multiDevice: false, + offlineDownload: nil ), CourseBlock( blockId: "3", @@ -479,7 +497,8 @@ struct CourseUnitView_Previews: PreviewProvider { studentUrl: "3", webUrl: "3", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ), CourseBlock( blockId: "4", @@ -494,7 +513,8 @@ struct CourseUnitView_Previews: PreviewProvider { studentUrl: "4", webUrl: "4", encodedVideo: nil, - multiDevice: false + multiDevice: false, + offlineDownload: nil ), ] diff --git a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift index 8f4be45b8..8e3ef4497 100644 --- a/Course/Course/Presentation/Unit/CourseUnitViewModel.swift +++ b/Course/Course/Presentation/Unit/CourseUnitViewModel.swift @@ -9,9 +9,9 @@ import SwiftUI import Core public enum LessonType: Equatable { - case web(url: String, injections: [WebviewInjection]) - case youtube(youtubeVideoUrl: String, blockID: String) - case video(videoUrl: String, blockID: String) + case web(url: String, injections: [WebviewInjection], blockId: String, isDownloadable: Bool) + case youtube(youtubeVideoUrl: String, blockId: String) + case video(videoUrl: String, blockId: String) case unknown(String) case discussion(String, String, String) @@ -22,34 +22,66 @@ public enum LessonType: Equatable { return .unknown(block.studentUrl) case .unknown: if let multiDevice = block.multiDevice, multiDevice { - return .web(url: block.studentUrl, injections: mandatoryInjections) + return .web( + url: block.studentUrl, + injections: mandatoryInjections, + blockId: block.id, + isDownloadable: block.isDownloadable + ) } else { return .unknown(block.studentUrl) } case .html: - return .web(url: block.studentUrl, injections: mandatoryInjections) + return .web( + url: block.studentUrl, + injections: mandatoryInjections, + blockId: block.id, + isDownloadable: block.isDownloadable + ) 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 { - return .video(videoUrl: encodedVideo, blockID: block.id) + return .video(videoUrl: encodedVideo, blockId: block.id) } else if let youtubeVideoUrl = block.encodedVideo?.youtubeVideoUrl { - return .youtube(youtubeVideoUrl: youtubeVideoUrl, blockID: block.id) + return .youtube(youtubeVideoUrl: youtubeVideoUrl, blockId: block.id) } else if let encodedVideo = block.encodedVideo?.video(streamingQuality: streamingQuality)?.url { - return .video(videoUrl: encodedVideo, blockID: block.id) + return .video(videoUrl: encodedVideo, blockId: block.id) + } else if let encodedVideo = block.encodedVideo?.video(downloadQuality: DownloadQuality.auto)?.url { + return .video(videoUrl: encodedVideo, blockId: block.id) } else { return .unknown(block.studentUrl) } case .problem: - return .web(url: block.studentUrl, injections: mandatoryInjections) + return .web( + url: block.studentUrl, + injections: mandatoryInjections, + blockId: block.id, + isDownloadable: block.isDownloadable + ) case .dragAndDropV2: - return .web(url: block.studentUrl, injections: mandatoryInjections + [.dragAndDropCss]) + return .web( + url: block.studentUrl, + injections: mandatoryInjections + [.dragAndDropCss], + blockId: block.id, + isDownloadable: block.isDownloadable + ) case .survey: - return .web(url: block.studentUrl, injections: mandatoryInjections + [.surveyCSS]) + return .web( + url: block.studentUrl, + injections: mandatoryInjections + [.surveyCSS], + blockId: block.id, + isDownloadable: block.isDownloadable + ) case .openassessment, .peerInstructionTool: - return .web(url: block.studentUrl, injections: mandatoryInjections) + return .web( + url: block.studentUrl, + injections: mandatoryInjections, + blockId: block.id, + isDownloadable: block.isDownloadable + ) } } } @@ -238,6 +270,10 @@ public class CourseUnitViewModel: ObservableObject { return URL(string: url) } } + + func urlForOfflineContent(blockId: String) -> URL? { + return manager.fileUrl(for: blockId) + } func trackFinishVerticalBackToOutlineClicked() { analytics.finishVerticalBackToOutlineClicked(courseId: courseID, courseName: courseName) diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift index 7b7310fb4..a00a2de0d 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift @@ -84,7 +84,8 @@ struct CourseUnitDropDownCell_Previews: PreviewProvider { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ) ] ) diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift index fea9801f3..86459a13a 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift @@ -58,7 +58,8 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ), CourseBlock( blockId: "2", @@ -73,7 +74,8 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { studentUrl: "2", webUrl: "2", encodedVideo: nil, - multiDevice: false + multiDevice: false, + offlineDownload: nil ), CourseBlock( blockId: "3", @@ -88,7 +90,8 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { studentUrl: "3", webUrl: "3", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ), CourseBlock( blockId: "4", @@ -103,7 +106,8 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { studentUrl: "4", webUrl: "4", encodedVideo: nil, - multiDevice: false + multiDevice: false, + offlineDownload: nil ) ] diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift index dd7ddbc75..e5f289ea4 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift @@ -71,7 +71,8 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ), CourseBlock( @@ -87,7 +88,8 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { studentUrl: "2", webUrl: "2", encodedVideo: nil, - multiDevice: false + multiDevice: false, + offlineDownload: nil ), CourseBlock( @@ -103,7 +105,8 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { studentUrl: "3", webUrl: "3", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ), CourseBlock( @@ -119,7 +122,8 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { studentUrl: "4", webUrl: "4", encodedVideo: nil, - multiDevice: false + multiDevice: false, + offlineDownload: nil ) ] diff --git a/Course/Course/Presentation/Unit/Subviews/OfflineContentView.swift b/Course/Course/Presentation/Unit/Subviews/OfflineContentView.swift new file mode 100644 index 000000000..cfe51c9f2 --- /dev/null +++ b/Course/Course/Presentation/Unit/Subviews/OfflineContentView.swift @@ -0,0 +1,65 @@ +// +// OfflineContentView.swift +// Course +// +// Created by  Stepanok Ivan on 22.06.2024. +// + +import SwiftUI +import Core +import Theme + +public struct OfflineContentView: View { + + enum OfflineContentState { + case notDownloaded + case notAvailableOffline + + var title: String { + switch self { + case .notDownloaded: + return CourseLocalization.Offline.NotDownloaded.title + case .notAvailableOffline: + return CourseLocalization.Offline.NotAvaliable.title + } + } + + var description: String { + switch self { + case .notDownloaded: + return CourseLocalization.Offline.NotDownloaded.description + case .notAvailableOffline: + return CourseLocalization.Offline.NotAvaliable.description + } + } + } + + @State private var contentState: OfflineContentState + + public init(isDownloadable: Bool) { + contentState = isDownloadable ? .notDownloaded : .notAvailableOffline + } + + public var body: some View { + VStack(spacing: 0) { + Spacer() + CoreAssets.notAvaliable.swiftUIImage + Text(contentState.title) + .font(Theme.Fonts.titleLarge) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 40) + Text(contentState.description) + .font(Theme.Fonts.bodyLarge) + .multilineTextAlignment(.center) + .frame(maxWidth: .infinity) + .padding(.top, 12) + Spacer() + } + .padding(24) + } +} + +#Preview { + OfflineContentView(isDownloadable: true) +} diff --git a/Course/Course/Presentation/Unit/Subviews/WebView.swift b/Course/Course/Presentation/Unit/Subviews/WebView.swift index 8d1b0c7ad..b8823bda6 100644 --- a/Course/Course/Presentation/Unit/Subviews/WebView.swift +++ b/Course/Course/Presentation/Unit/Subviews/WebView.swift @@ -12,15 +12,20 @@ import Theme struct WebView: View { let url: String + let localUrl: String? let injections: [WebviewInjection] + let blockID: String var roundedBackgroundEnabled: Bool = true - + var body: some View { VStack(spacing: 0) { WebUnitView( url: url, + dataUrl: localUrl, viewModel: Container.shared.resolve(WebUnitViewModel.self)!, - injections: injections + connectivity: Connectivity(), + injections: injections, + blockID: blockID ) if roundedBackgroundEnabled { Spacer(minLength: 5) diff --git a/Course/Course/SwiftGen/Strings.swift b/Course/Course/SwiftGen/Strings.swift index 8cf2f60a2..6b24786a9 100644 --- a/Course/Course/SwiftGen/Strings.swift +++ b/Course/Course/SwiftGen/Strings.swift @@ -57,6 +57,102 @@ public enum CourseLocalization { public static func progressCompleted(_ p1: Any, _ p2: Any) -> String { return CourseLocalization.tr("Localizable", "COURSE.PROGRESS_COMPLETED", String(describing: p1), String(describing: p2), fallback: "%@ of %@ assignments complete") } + public enum Alert { + /// Cancel + public static let cancel = CourseLocalization.tr("Localizable", "COURSE.ALERT.CANCEL", fallback: "Cancel") + /// Close + public static let close = CourseLocalization.tr("Localizable", "COURSE.ALERT.CLOSE", fallback: "Close") + /// Downloading this content will use %@ of cellular data. + public static func confirmDownloadCellularDescription(_ p1: Any) -> String { + return CourseLocalization.tr("Localizable", "COURSE.ALERT.CONFIRM_DOWNLOAD_CELLULAR_DESCRIPTION", String(describing: p1), fallback: "Downloading this content will use %@ of cellular data.") + } + /// Download on Cellular? + public static let confirmDownloadCellularTitle = CourseLocalization.tr("Localizable", "COURSE.ALERT.CONFIRM_DOWNLOAD_CELLULAR_TITLE", fallback: "Download on Cellular?") + /// Downloading this %@ of content will save available blocks offline. + public static func confirmDownloadDescription(_ p1: Any) -> String { + return CourseLocalization.tr("Localizable", "COURSE.ALERT.CONFIRM_DOWNLOAD_DESCRIPTION", String(describing: p1), fallback: "Downloading this %@ of content will save available blocks offline.") + } + /// Confirm Download + public static let confirmDownloadTitle = CourseLocalization.tr("Localizable", "COURSE.ALERT.CONFIRM_DOWNLOAD_TITLE", fallback: "Confirm Download") + /// Download + public static let download = CourseLocalization.tr("Localizable", "COURSE.ALERT.DOWNLOAD", fallback: "Download") + /// Remove + public static let remove = CourseLocalization.tr("Localizable", "COURSE.ALERT.REMOVE", fallback: "Remove") + /// Removing this content will free up %@. + public static func removeDescription(_ p1: Any) -> String { + return CourseLocalization.tr("Localizable", "COURSE.ALERT.REMOVE_DESCRIPTION", String(describing: p1), fallback: "Removing this content will free up %@.") + } + /// Remove Offline Content? + public static let removeTitle = CourseLocalization.tr("Localizable", "COURSE.ALERT.REMOVE_TITLE", fallback: "Remove Offline Content?") + /// Try again + public static let tryAgain = CourseLocalization.tr("Localizable", "COURSE.ALERT.TRY_AGAIN", fallback: "Try again") + } + public enum Error { + /// Unfortunately, this content failed to download. Please try again later or report this issue. + public static let downloadFailedDescription = CourseLocalization.tr("Localizable", "COURSE.ERROR.DOWNLOAD_FAILED_DESCRIPTION", fallback: "Unfortunately, this content failed to download. Please try again later or report this issue.") + /// Download Failed + public static let downloadFailedTitle = CourseLocalization.tr("Localizable", "COURSE.ERROR.DOWNLOAD_FAILED_TITLE", fallback: "Download Failed") + /// Downloading this content requires an active internet connection. Please connect to the internet and try again. + public static let noInternetConnectionDescription = CourseLocalization.tr("Localizable", "COURSE.ERROR.NO_INTERNET_CONNECTION_DESCRIPTION", fallback: "Downloading this content requires an active internet connection. Please connect to the internet and try again.") + /// No Internet Connection + public static let noInternetConnectionTitle = CourseLocalization.tr("Localizable", "COURSE.ERROR.NO_INTERNET_CONNECTION_TITLE", fallback: "No Internet Connection") + /// Downloading this content requires an active WiFi connection. Please connect to a WiFi network and try again. + public static let wifiRequiredDescription = CourseLocalization.tr("Localizable", "COURSE.ERROR.WIFI_REQUIRED_DESCRIPTION", fallback: "Downloading this content requires an active WiFi connection. Please connect to a WiFi network and try again.") + /// Wi-Fi Required + public static let wifiRequiredTitle = CourseLocalization.tr("Localizable", "COURSE.ERROR.WIFI_REQUIRED_TITLE", fallback: "Wi-Fi Required") + } + public enum LargestDownloads { + /// Done + public static let done = CourseLocalization.tr("Localizable", "COURSE.LARGEST_DOWNLOADS.DONE", fallback: "Done") + /// Edit + public static let edit = CourseLocalization.tr("Localizable", "COURSE.LARGEST_DOWNLOADS.EDIT", fallback: "Edit") + /// Remove all downloads + public static let removeDownloads = CourseLocalization.tr("Localizable", "COURSE.LARGEST_DOWNLOADS.REMOVE_DOWNLOADS", fallback: "Remove all downloads") + /// Largest Downloads + public static let title = CourseLocalization.tr("Localizable", "COURSE.LARGEST_DOWNLOADS.TITLE", fallback: "Largest Downloads") + } + public enum Offline { + /// %@%% of this course can be completed offline. + public static func canBeCompleted(_ p1: Any) -> String { + return CourseLocalization.tr("Localizable", "COURSE.OFFLINE.CAN_BE_COMPLETED", String(describing: p1), fallback: "%@%% of this course can be completed offline.") + } + /// Cancel Course Download + public static let cancelCourseDownload = CourseLocalization.tr("Localizable", "COURSE.OFFLINE.CANCEL_COURSE_DOWNLOAD", fallback: "Cancel Course Download") + /// Download all + public static let downloadAll = CourseLocalization.tr("Localizable", "COURSE.OFFLINE.DOWNLOAD_ALL", fallback: "Download all") + /// %@%% of this course is downloadable. + public static func downloadable(_ p1: Any) -> String { + return CourseLocalization.tr("Localizable", "COURSE.OFFLINE.DOWNLOADABLE", String(describing: p1), fallback: "%@%% of this course is downloadable.") + } + /// %@%% of this course is visible on mobile. + public static func visible(_ p1: Any) -> String { + return CourseLocalization.tr("Localizable", "COURSE.OFFLINE.VISIBLE", String(describing: p1), fallback: "%@%% of this course is visible on mobile.") + } + /// You can download course content offline to learn on the go, without requiring an active internet connection or using mobile data. + public static let youCanDownload = CourseLocalization.tr("Localizable", "COURSE.OFFLINE.YOU_CAN_DOWNLOAD", fallback: "You can download course content offline to learn on the go, without requiring an active internet connection or using mobile data.") + /// None of this course’s content is currently avaliable to download offline. + public static let youCantDownload = CourseLocalization.tr("Localizable", "COURSE.OFFLINE.YOU_CANT_DOWNLOAD", fallback: "None of this course’s content is currently avaliable to download offline.") + } + public enum StorageAlert { + /// Your device does not have enough free space to download this content. Please free up some space and try again. + public static let description = CourseLocalization.tr("Localizable", "COURSE.STORAGE_ALERT.DESCRIPTION", fallback: "Your device does not have enough free space to download this content. Please free up some space and try again.") + /// Device Storage Full + public static let title = CourseLocalization.tr("Localizable", "COURSE.STORAGE_ALERT.TITLE", fallback: "Device Storage Full") + /// %@ used, %@ free + public static func usedAndFree(_ p1: Any, _ p2: Any) -> String { + return CourseLocalization.tr("Localizable", "COURSE.STORAGE_ALERT.USED_AND_FREE", String(describing: p1), String(describing: p2), fallback: "%@ used, %@ free") + } + } + public enum TotalProgress { + /// Available to Download + public static let avaliableToDownload = CourseLocalization.tr("Localizable", "COURSE.TOTAL_PROGRESS.AVALIABLE_TO_DOWNLOAD", fallback: "Available to Download") + /// Downloaded + public static let downloaded = CourseLocalization.tr("Localizable", "COURSE.TOTAL_PROGRESS.DOWNLOADED", fallback: "Downloaded") + /// Downloading + public static let downloading = CourseLocalization.tr("Localizable", "COURSE.TOTAL_PROGRESS.DOWNLOADING", fallback: "Downloading") + /// Ready to Download + public static let readyToDownload = CourseLocalization.tr("Localizable", "COURSE.TOTAL_PROGRESS.READY_TO_DOWNLOAD", fallback: "Ready to Download") + } } public enum Courseware { /// Back to outline @@ -93,6 +189,8 @@ public enum CourseLocalization { public static let handoutsInDeveloping = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.HANDOUTS_IN_DEVELOPING", fallback: "Handouts In developing") /// Home public static let home = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.HOME", fallback: "Home") + /// Offline + public static let offline = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.OFFLINE", fallback: "Offline") /// Videos public static let videos = CourseLocalization.tr("Localizable", "COURSE_CONTAINER.VIDEOS", fallback: "Videos") } @@ -232,6 +330,20 @@ public enum CourseLocalization { /// 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 Offline { + public enum NotAvaliable { + /// Explore other parts of this course or view this when you reconnect. + public static let description = CourseLocalization.tr("Localizable", "OFFLINE.NOT_AVALIABLE.DESCRIPTION", fallback: "Explore other parts of this course or view this when you reconnect.") + /// This component is not yet available offline + public static let title = CourseLocalization.tr("Localizable", "OFFLINE.NOT_AVALIABLE.TITLE", fallback: "This component is not yet available offline") + } + public enum NotDownloaded { + /// Explore other parts of this course or download this when you reconnect. + public static let description = CourseLocalization.tr("Localizable", "OFFLINE.NOT_DOWNLOADED.DESCRIPTION", fallback: "Explore other parts of this course or download this when you reconnect.") + /// This component is not downloaded + public static let title = CourseLocalization.tr("Localizable", "OFFLINE.NOT_DOWNLOADED.TITLE", fallback: "This component is not downloaded") + } + } public enum Outline { /// Certificate public static let certificate = CourseLocalization.tr("Localizable", "OUTLINE.CERTIFICATE", fallback: "Certificate") diff --git a/Course/Course/en.lproj/Localizable.strings b/Course/Course/en.lproj/Localizable.strings index 424ecf737..2cd63e571 100644 --- a/Course/Course/en.lproj/Localizable.strings +++ b/Course/Course/en.lproj/Localizable.strings @@ -38,6 +38,7 @@ "COURSE_CONTAINER.HOME" = "Home"; "COURSE_CONTAINER.VIDEOS" = "Videos"; +"COURSE_CONTAINER.OFFLINE" = "Offline"; "COURSE_CONTAINER.DATES" = "Dates"; "COURSE_CONTAINER.DISCUSSIONS" = "Discussions"; "COURSE_CONTAINER.HANDOUTS" = "More"; @@ -111,6 +112,59 @@ "COURSE.DUE_TOMORROW" = "Due Tomorrow"; "COURSE.PROGRESS_COMPLETED" = "%@ of %@ assignments complete"; + +"COURSE.ALERT.CANCEL" = "Cancel"; +"COURSE.ALERT.CLOSE" = "Close"; +"COURSE.ALERT.REMOVE" = "Remove"; +"COURSE.ALERT.DOWNLOAD" = "Download"; +"COURSE.ALERT.TRY_AGAIN" = "Try again"; + +"COURSE.ALERT.REMOVE_TITLE" = "Remove Offline Content?"; +"COURSE.ALERT.CONFIRM_DOWNLOAD_TITLE" = "Confirm Download"; +"COURSE.ALERT.CONFIRM_DOWNLOAD_CELLULAR_TITLE" = "Download on Cellular?"; + +"COURSE.ALERT.REMOVE_DESCRIPTION" = "Removing this content will free up %@."; +"COURSE.ALERT.CONFIRM_DOWNLOAD_DESCRIPTION" = "Downloading this %@ of content will save available blocks offline."; +"COURSE.ALERT.CONFIRM_DOWNLOAD_CELLULAR_DESCRIPTION" = "Downloading this content will use %@ of cellular data."; + +"COURSE.ERROR.DOWNLOAD_FAILED_TITLE" = "Download Failed"; +"COURSE.ERROR.NO_INTERNET_CONNECTION_TITLE" = "No Internet Connection"; +"COURSE.ERROR.WIFI_REQUIRED_TITLE" = "Wi-Fi Required"; + +"COURSE.ERROR.DOWNLOAD_FAILED_DESCRIPTION" = "Unfortunately, this content failed to download. Please try again later or report this issue."; +"COURSE.ERROR.NO_INTERNET_CONNECTION_DESCRIPTION" = "Downloading this content requires an active internet connection. Please connect to the internet and try again."; +"COURSE.ERROR.WIFI_REQUIRED_DESCRIPTION" = "Downloading this content requires an active WiFi connection. Please connect to a WiFi network and try again."; + +"COURSE.STORAGE_ALERT.TITLE" = "Device Storage Full"; +"COURSE.STORAGE_ALERT.DESCRIPTION" = "Your device does not have enough free space to download this content. Please free up some space and try again."; +"COURSE.STORAGE_ALERT.USED_AND_FREE" = "%@ used, %@ free"; + +"COURSE.LARGEST_DOWNLOADS.TITLE" = "Largest Downloads"; +"COURSE.LARGEST_DOWNLOADS.DONE" = "Done"; +"COURSE.LARGEST_DOWNLOADS.EDIT" = "Edit"; +"COURSE.LARGEST_DOWNLOADS.REMOVE_DOWNLOADS" = "Remove all downloads"; + +"COURSE.OFFLINE.VISIBLE" = "%@%% of this course is visible on mobile."; +"COURSE.OFFLINE.DOWNLOADABLE" = "%@%% of this course is downloadable."; +"COURSE.OFFLINE.CAN_BE_COMPLETED" = "%@%% of this course can be completed offline."; + +"COURSE.TOTAL_PROGRESS.DOWNLOADED" = "Downloaded"; +"COURSE.TOTAL_PROGRESS.DOWNLOADING" = "Downloading"; +"COURSE.TOTAL_PROGRESS.AVALIABLE_TO_DOWNLOAD" = "Available to Download"; +"COURSE.TOTAL_PROGRESS.READY_TO_DOWNLOAD" = "Ready to Download"; + + +"COURSE.OFFLINE.DOWNLOAD_ALL" = "Download all"; +"COURSE.OFFLINE.CANCEL_COURSE_DOWNLOAD" = "Cancel Course Download"; + +"COURSE.OFFLINE.YOU_CAN_DOWNLOAD" = "You can download course content offline to learn on the go, without requiring an active internet connection or using mobile data."; +"COURSE.OFFLINE.YOU_CANT_DOWNLOAD" = "None of this course’s content is currently avaliable to download offline."; + +"OFFLINE.NOT_DOWNLOADED.TITLE" = "This component is not downloaded"; +"OFFLINE.NOT_DOWNLOADED.DESCRIPTION" = "Explore other parts of this course or download this when you reconnect."; +"OFFLINE.NOT_AVALIABLE.TITLE" = "This component is not yet available offline"; +"OFFLINE.NOT_AVALIABLE.DESCRIPTION" = "Explore other parts of this course or view this when you reconnect."; + "CALENDAR_SYNC_STATUS.SYNCED" = "Synced to Calendar"; "CALENDAR_SYNC_STATUS.FAILED" = "Calendar Sync Failed"; "CALENDAR_SYNC_STATUS.OFFLINE" = "Offline"; diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index 067556faf..f6bb64618 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -1466,6 +1466,611 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { } } +// MARK: - CorePersistenceProtocol + +open class CorePersistenceProtocolMock: CorePersistenceProtocol, 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 set(userId: Int) { + addInvocation(.m_set__userId_userId(Parameter.value(`userId`))) + let perform = methodPerformValue(.m_set__userId_userId(Parameter.value(`userId`))) as? (Int) -> Void + perform?(`userId`) + } + + open func getUserID() -> Int? { + addInvocation(.m_getUserID) + let perform = methodPerformValue(.m_getUserID) as? () -> Void + perform?() + var __value: Int? = nil + do { + __value = try methodReturnValue(.m_getUserID).casted() + } catch { + // do nothing + } + return __value + } + + open func publisher() -> AnyPublisher { + addInvocation(.m_publisher) + let perform = methodPerformValue(.m_publisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_publisher).casted() + } catch { + onFatalFailure("Stub return value not specified for publisher(). Use given") + Failure("Stub return value not specified for publisher(). Use given") + } + return __value + } + + open func addToDownloadQueue(tasks: [DownloadDataTask]) { + addInvocation(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) as? ([DownloadDataTask]) -> Void + perform?(`tasks`) + } + + open func saveOfflineProgress(progress: OfflineProgress) { + addInvocation(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) + let perform = methodPerformValue(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) as? (OfflineProgress) -> Void + perform?(`progress`) + } + + open func loadProgress(for blockID: String) -> OfflineProgress? { + addInvocation(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + var __value: OfflineProgress? = nil + do { + __value = try methodReturnValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))).casted() + } catch { + // do nothing + } + return __value + } + + open func loadAllOfflineProgress() -> [OfflineProgress] { + addInvocation(.m_loadAllOfflineProgress) + let perform = methodPerformValue(.m_loadAllOfflineProgress) as? () -> Void + perform?() + var __value: [OfflineProgress] + do { + __value = try methodReturnValue(.m_loadAllOfflineProgress).casted() + } catch { + onFatalFailure("Stub return value not specified for loadAllOfflineProgress(). Use given") + Failure("Stub return value not specified for loadAllOfflineProgress(). Use given") + } + return __value + } + + open func deleteProgress(for blockID: String) { + addInvocation(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + } + + open func deleteAllProgress() { + addInvocation(.m_deleteAllProgress) + let perform = methodPerformValue(.m_deleteAllProgress) as? () -> Void + perform?() + } + + open func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) { + addInvocation(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) as? ([CourseBlock], DownloadQuality) -> Void + perform?(`blocks`, `downloadQuality`) + } + + open func nextBlockForDownloading() -> DownloadDataTask? { + addInvocation(.m_nextBlockForDownloading) + let perform = methodPerformValue(.m_nextBlockForDownloading) as? () -> Void + perform?() + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_nextBlockForDownloading).casted() + } catch { + // do nothing + } + return __value + } + + open func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) { + addInvocation(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) + let perform = methodPerformValue(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) as? (String, DownloadState, Data?) -> Void + perform?(`id`, `state`, `resumeData`) + } + + open func deleteDownloadDataTask(id: String) throws { + addInvocation(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) + let perform = methodPerformValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) as? (String) -> Void + perform?(`id`) + do { + _ = try methodReturnValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func saveDownloadDataTask(_ task: DownloadDataTask) { + addInvocation(.m_saveDownloadDataTask__task(Parameter.value(`task`))) + let perform = methodPerformValue(.m_saveDownloadDataTask__task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void + perform?(`task`) + } + + open func downloadDataTask(for blockId: String) -> DownloadDataTask? { + addInvocation(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void + perform?(`blockId`) + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))).casted() + } catch { + // do nothing + } + return __value + } + + open func getDownloadDataTasks() -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasks) + let perform = methodPerformValue(.m_getDownloadDataTasks) as? () -> Void + perform?() + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasks).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasks(). Use given") + Failure("Stub return value not specified for getDownloadDataTasks(). Use given") + } + return __value + } + + open func getDownloadDataTasksForCourse(_ courseId: String) -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + } + return __value + } + + + fileprivate enum MethodType { + case m_set__userId_userId(Parameter) + case m_getUserID + case m_publisher + case m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>) + case m_saveOfflineProgress__progress_progress(Parameter) + case m_loadProgress__for_blockID(Parameter) + case m_loadAllOfflineProgress + case m_deleteProgress__for_blockID(Parameter) + case m_deleteAllProgress + case m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>, Parameter) + case m_nextBlockForDownloading + case m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter, Parameter, Parameter) + case m_deleteDownloadDataTask__id_id(Parameter) + case m_saveDownloadDataTask__task(Parameter) + case m_downloadDataTask__for_blockId(Parameter) + case m_getDownloadDataTasks + case m_getDownloadDataTasksForCourse__courseId(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_set__userId_userId(let lhsUserid), .m_set__userId_userId(let rhsUserid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUserid, rhs: rhsUserid, with: matcher), lhsUserid, rhsUserid, "userId")) + return Matcher.ComparisonResult(results) + + case (.m_getUserID, .m_getUserID): return .match + + case (.m_publisher, .m_publisher): return .match + + case (.m_addToDownloadQueue__tasks_tasks(let lhsTasks), .m_addToDownloadQueue__tasks_tasks(let rhsTasks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTasks, rhs: rhsTasks, with: matcher), lhsTasks, rhsTasks, "tasks")) + return Matcher.ComparisonResult(results) + + case (.m_saveOfflineProgress__progress_progress(let lhsProgress), .m_saveOfflineProgress__progress_progress(let rhsProgress)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsProgress, rhs: rhsProgress, with: matcher), lhsProgress, rhsProgress, "progress")) + return Matcher.ComparisonResult(results) + + case (.m_loadProgress__for_blockID(let lhsBlockid), .m_loadProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_loadAllOfflineProgress, .m_loadAllOfflineProgress): return .match + + case (.m_deleteProgress__for_blockID(let lhsBlockid), .m_deleteProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_deleteAllProgress, .m_deleteAllProgress): return .match + + case (.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let lhsBlocks, let lhsDownloadquality), .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let rhsBlocks, let rhsDownloadquality)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDownloadquality, rhs: rhsDownloadquality, with: matcher), lhsDownloadquality, rhsDownloadquality, "downloadQuality")) + return Matcher.ComparisonResult(results) + + case (.m_nextBlockForDownloading, .m_nextBlockForDownloading): return .match + + case (.m_updateDownloadState__id_idstate_stateresumeData_resumeData(let lhsId, let lhsState, let lhsResumedata), .m_updateDownloadState__id_idstate_stateresumeData_resumeData(let rhsId, let rhsState, let rhsResumedata)): + 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: lhsState, rhs: rhsState, with: matcher), lhsState, rhsState, "state")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsResumedata, rhs: rhsResumedata, with: matcher), lhsResumedata, rhsResumedata, "resumeData")) + return Matcher.ComparisonResult(results) + + case (.m_deleteDownloadDataTask__id_id(let lhsId), .m_deleteDownloadDataTask__id_id(let rhsId)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "id")) + return Matcher.ComparisonResult(results) + + case (.m_saveDownloadDataTask__task(let lhsTask), .m_saveDownloadDataTask__task(let rhsTask)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "_ task")) + return Matcher.ComparisonResult(results) + + case (.m_downloadDataTask__for_blockId(let lhsBlockid), .m_downloadDataTask__for_blockId(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + return Matcher.ComparisonResult(results) + + case (.m_getDownloadDataTasks, .m_getDownloadDataTasks): return .match + + case (.m_getDownloadDataTasksForCourse__courseId(let lhsCourseid), .m_getDownloadDataTasksForCourse__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 + } + } + + func intValue() -> Int { + switch self { + case let .m_set__userId_userId(p0): return p0.intValue + case .m_getUserID: return 0 + case .m_publisher: return 0 + case let .m_addToDownloadQueue__tasks_tasks(p0): return p0.intValue + case let .m_saveOfflineProgress__progress_progress(p0): return p0.intValue + case let .m_loadProgress__for_blockID(p0): return p0.intValue + case .m_loadAllOfflineProgress: return 0 + case let .m_deleteProgress__for_blockID(p0): return p0.intValue + case .m_deleteAllProgress: return 0 + case let .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(p0, p1): return p0.intValue + p1.intValue + case .m_nextBlockForDownloading: return 0 + case let .m_updateDownloadState__id_idstate_stateresumeData_resumeData(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_deleteDownloadDataTask__id_id(p0): return p0.intValue + case let .m_saveDownloadDataTask__task(p0): return p0.intValue + case let .m_downloadDataTask__for_blockId(p0): return p0.intValue + case .m_getDownloadDataTasks: return 0 + case let .m_getDownloadDataTasksForCourse__courseId(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_set__userId_userId: return ".set(userId:)" + case .m_getUserID: return ".getUserID()" + case .m_publisher: return ".publisher()" + case .m_addToDownloadQueue__tasks_tasks: return ".addToDownloadQueue(tasks:)" + case .m_saveOfflineProgress__progress_progress: return ".saveOfflineProgress(progress:)" + case .m_loadProgress__for_blockID: return ".loadProgress(for:)" + case .m_loadAllOfflineProgress: return ".loadAllOfflineProgress()" + case .m_deleteProgress__for_blockID: return ".deleteProgress(for:)" + case .m_deleteAllProgress: return ".deleteAllProgress()" + case .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality: return ".addToDownloadQueue(blocks:downloadQuality:)" + case .m_nextBlockForDownloading: return ".nextBlockForDownloading()" + case .m_updateDownloadState__id_idstate_stateresumeData_resumeData: return ".updateDownloadState(id:state:resumeData:)" + case .m_deleteDownloadDataTask__id_id: return ".deleteDownloadDataTask(id:)" + case .m_saveDownloadDataTask__task: return ".saveDownloadDataTask(_:)" + case .m_downloadDataTask__for_blockId: return ".downloadDataTask(for:)" + case .m_getDownloadDataTasks: return ".getDownloadDataTasks()" + case .m_getDownloadDataTasksForCourse__courseId: return ".getDownloadDataTasksForCourse(_:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func getUserID(willReturn: Int?...) -> MethodStub { + return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func publisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadProgress(for blockID: Parameter, willReturn: OfflineProgress?...) -> MethodStub { + return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadAllOfflineProgress(willReturn: [OfflineProgress]...) -> MethodStub { + return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func nextBlockForDownloading(willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func downloadDataTask(for blockId: Parameter, willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasks(willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getUserID(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Int?] = [] + let given: Given = { return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Int?).self) + willProduce(stubber) + return given + } + public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func loadProgress(for blockID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [OfflineProgress?] = [] + let given: Given = { return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (OfflineProgress?).self) + willProduce(stubber) + return given + } + public static func loadAllOfflineProgress(willProduce: (Stubber<[OfflineProgress]>) -> Void) -> MethodStub { + let willReturn: [[OfflineProgress]] = [] + let given: Given = { return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([OfflineProgress]).self) + willProduce(stubber) + return given + } + public static func nextBlockForDownloading(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func downloadDataTask(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func deleteDownloadDataTask(id: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func deleteDownloadDataTask(id: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func set(userId: Parameter) -> Verify { return Verify(method: .m_set__userId_userId(`userId`))} + public static func getUserID() -> Verify { return Verify(method: .m_getUserID)} + public static func publisher() -> Verify { return Verify(method: .m_publisher)} + public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>) -> Verify { return Verify(method: .m_addToDownloadQueue__tasks_tasks(`tasks`))} + public static func saveOfflineProgress(progress: Parameter) -> Verify { return Verify(method: .m_saveOfflineProgress__progress_progress(`progress`))} + public static func loadProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_loadProgress__for_blockID(`blockID`))} + public static func loadAllOfflineProgress() -> Verify { return Verify(method: .m_loadAllOfflineProgress)} + public static func deleteProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_deleteProgress__for_blockID(`blockID`))} + public static func deleteAllProgress() -> Verify { return Verify(method: .m_deleteAllProgress)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`))} + public static func nextBlockForDownloading() -> Verify { return Verify(method: .m_nextBlockForDownloading)} + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter) -> Verify { return Verify(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`))} + public static func deleteDownloadDataTask(id: Parameter) -> Verify { return Verify(method: .m_deleteDownloadDataTask__id_id(`id`))} + public static func saveDownloadDataTask(_ task: Parameter) -> Verify { return Verify(method: .m_saveDownloadDataTask__task(`task`))} + public static func downloadDataTask(for blockId: Parameter) -> Verify { return Verify(method: .m_downloadDataTask__for_blockId(`blockId`))} + public static func getDownloadDataTasks() -> Verify { return Verify(method: .m_getDownloadDataTasks)} + public static func getDownloadDataTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func set(userId: Parameter, perform: @escaping (Int) -> Void) -> Perform { + return Perform(method: .m_set__userId_userId(`userId`), performs: perform) + } + public static func getUserID(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getUserID, performs: perform) + } + public static func publisher(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_publisher, performs: perform) + } + public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>, perform: @escaping ([DownloadDataTask]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__tasks_tasks(`tasks`), performs: perform) + } + public static func saveOfflineProgress(progress: Parameter, perform: @escaping (OfflineProgress) -> Void) -> Perform { + return Perform(method: .m_saveOfflineProgress__progress_progress(`progress`), performs: perform) + } + public static func loadProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_loadProgress__for_blockID(`blockID`), performs: perform) + } + public static func loadAllOfflineProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_loadAllOfflineProgress, performs: perform) + } + public static func deleteProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteProgress__for_blockID(`blockID`), performs: perform) + } + public static func deleteAllProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllProgress, performs: perform) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter, perform: @escaping ([CourseBlock], DownloadQuality) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`), performs: perform) + } + public static func nextBlockForDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_nextBlockForDownloading, performs: perform) + } + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter, perform: @escaping (String, DownloadState, Data?) -> Void) -> Perform { + return Perform(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`), performs: perform) + } + public static func deleteDownloadDataTask(id: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteDownloadDataTask__id_id(`id`), performs: perform) + } + public static func saveDownloadDataTask(_ task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { + return Perform(method: .m_saveDownloadDataTask__task(`task`), performs: perform) + } + public static func downloadDataTask(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_downloadDataTask__for_blockId(`blockId`), performs: perform) + } + public static func getDownloadDataTasks(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasks, performs: perform) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), 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 { @@ -1570,6 +2175,12 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { perform?(`courseId`, `courseName`) } + open func courseOutlineOfflineTabClicked(courseId: String, courseName: String) { + addInvocation(.m_courseOutlineOfflineTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) + let perform = methodPerformValue(.m_courseOutlineOfflineTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) as? (String, String) -> Void + perform?(`courseId`, `courseName`) + } + open func courseOutlineDatesTabClicked(courseId: String, courseName: String) { addInvocation(.m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) let perform = methodPerformValue(.m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(Parameter.value(`courseId`), Parameter.value(`courseName`))) as? (String, String) -> Void @@ -1666,6 +2277,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { case m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) + case m_courseOutlineOfflineTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) case m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(Parameter, Parameter) @@ -1756,6 +2368,12 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) return Matcher.ComparisonResult(results) + case (.m_courseOutlineOfflineTabClicked__courseId_courseIdcourseName_courseName(let lhsCourseid, let lhsCoursename), .m_courseOutlineOfflineTabClicked__courseId_courseIdcourseName_courseName(let rhsCourseid, let rhsCoursename)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + return Matcher.ComparisonResult(results) + case (.m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(let lhsCourseid, let lhsCoursename), .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(let rhsCourseid, let rhsCoursename)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) @@ -1876,6 +2494,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { case let .m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue + case let .m_courseOutlineOfflineTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue case let .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(p0, p1): return p0.intValue + p1.intValue @@ -1904,6 +2523,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { case .m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName: return ".finishVerticalBackToOutlineClicked(courseId:courseName:)" case .m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineCourseTabClicked(courseId:courseName:)" case .m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineVideosTabClicked(courseId:courseName:)" + case .m_courseOutlineOfflineTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineOfflineTabClicked(courseId:courseName:)" case .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineDatesTabClicked(courseId:courseName:)" case .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineDiscussionTabClicked(courseId:courseName:)" case .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName: return ".courseOutlineHandoutsTabClicked(courseId:courseName:)" @@ -1946,6 +2566,7 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { public static func finishVerticalBackToOutlineClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_finishVerticalBackToOutlineClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseOutlineCourseTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineCourseTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseOutlineVideosTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} + public static func courseOutlineOfflineTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineOfflineTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseOutlineDatesTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseOutlineDiscussionTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineDiscussionTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} public static func courseOutlineHandoutsTabClicked(courseId: Parameter, courseName: Parameter) -> Verify { return Verify(method: .m_courseOutlineHandoutsTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`))} @@ -1996,6 +2617,9 @@ open class CourseAnalyticsMock: CourseAnalytics, Mock { public static func courseOutlineVideosTabClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { return Perform(method: .m_courseOutlineVideosTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) } + public static func courseOutlineOfflineTabClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_courseOutlineOfflineTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) + } public static func courseOutlineDatesTabClicked(courseId: Parameter, courseName: Parameter, perform: @escaping (String, String) -> Void) -> Perform { return Perform(method: .m_courseOutlineDatesTabClicked__courseId_courseIdcourseName_courseName(`courseId`, `courseName`), performs: perform) } @@ -2203,6 +2827,22 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { return __value } + open func getSequentialsContainsBlocks(blockIds: [String], courseID: String) throws -> [CourseSequential] { + addInvocation(.m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(Parameter<[String]>.value(`blockIds`), Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(Parameter<[String]>.value(`blockIds`), Parameter.value(`courseID`))) as? ([String], String) -> Void + perform?(`blockIds`, `courseID`) + var __value: [CourseSequential] + do { + __value = try methodReturnValue(.m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(Parameter<[String]>.value(`blockIds`), Parameter.value(`courseID`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for getSequentialsContainsBlocks(blockIds: [String], courseID: String). Use given") + Failure("Stub return value not specified for getSequentialsContainsBlocks(blockIds: [String], courseID: String). Use given") + } catch { + throw error + } + return __value + } + open func blockCompletionRequest(courseID: String, blockID: String) throws { addInvocation(.m_blockCompletionRequest__courseID_courseIDblockID_blockID(Parameter.value(`courseID`), Parameter.value(`blockID`))) let perform = methodPerformValue(.m_blockCompletionRequest__courseID_courseIDblockID_blockID(Parameter.value(`courseID`), Parameter.value(`blockID`))) as? (String, String) -> Void @@ -2329,6 +2969,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { case m_getCourseBlocks__courseID_courseID(Parameter) case m_getCourseVideoBlocks__fullStructure_fullStructure(Parameter) case m_getLoadedCourseBlocks__courseID_courseID(Parameter) + case m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(Parameter<[String]>, Parameter) case m_blockCompletionRequest__courseID_courseIDblockID_blockID(Parameter, Parameter) case m_getHandouts__courseID_courseID(Parameter) case m_getUpdates__courseID_courseID(Parameter) @@ -2355,6 +2996,12 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) return Matcher.ComparisonResult(results) + case (.m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(let lhsBlockids, let lhsCourseid), .m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(let rhsBlockids, let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockids, rhs: rhsBlockids, with: matcher), lhsBlockids, rhsBlockids, "blockIds")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + return Matcher.ComparisonResult(results) + case (.m_blockCompletionRequest__courseID_courseIDblockID_blockID(let lhsCourseid, let lhsBlockid), .m_blockCompletionRequest__courseID_courseIDblockID_blockID(let rhsCourseid, let rhsBlockid)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) @@ -2405,6 +3052,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { case let .m_getCourseBlocks__courseID_courseID(p0): return p0.intValue case let .m_getCourseVideoBlocks__fullStructure_fullStructure(p0): return p0.intValue case let .m_getLoadedCourseBlocks__courseID_courseID(p0): return p0.intValue + case let .m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(p0, p1): return p0.intValue + p1.intValue case let .m_blockCompletionRequest__courseID_courseIDblockID_blockID(p0, p1): return p0.intValue + p1.intValue case let .m_getHandouts__courseID_courseID(p0): return p0.intValue case let .m_getUpdates__courseID_courseID(p0): return p0.intValue @@ -2420,6 +3068,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { case .m_getCourseBlocks__courseID_courseID: return ".getCourseBlocks(courseID:)" case .m_getCourseVideoBlocks__fullStructure_fullStructure: return ".getCourseVideoBlocks(fullStructure:)" case .m_getLoadedCourseBlocks__courseID_courseID: return ".getLoadedCourseBlocks(courseID:)" + case .m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID: return ".getSequentialsContainsBlocks(blockIds:courseID:)" case .m_blockCompletionRequest__courseID_courseIDblockID_blockID: return ".blockCompletionRequest(courseID:blockID:)" case .m_getHandouts__courseID_courseID: return ".getHandouts(courseID:)" case .m_getUpdates__courseID_courseID: return ".getUpdates(courseID:)" @@ -2450,6 +3099,9 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { public static func getLoadedCourseBlocks(courseID: Parameter, willReturn: CourseStructure...) -> MethodStub { return Given(method: .m_getLoadedCourseBlocks__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func getSequentialsContainsBlocks(blockIds: Parameter<[String]>, courseID: Parameter, willReturn: [CourseSequential]...) -> MethodStub { + return Given(method: .m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(`blockIds`, `courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func getHandouts(courseID: Parameter, willReturn: String?...) -> MethodStub { return Given(method: .m_getHandouts__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -2495,6 +3147,16 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { willProduce(stubber) return given } + public static func getSequentialsContainsBlocks(blockIds: Parameter<[String]>, courseID: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(`blockIds`, `courseID`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getSequentialsContainsBlocks(blockIds: Parameter<[String]>, courseID: Parameter, willProduce: (StubberThrows<[CourseSequential]>) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(`blockIds`, `courseID`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: ([CourseSequential]).self) + willProduce(stubber) + return given + } public static func blockCompletionRequest(courseID: Parameter, blockID: Parameter, willThrow: Error...) -> MethodStub { return Given(method: .m_blockCompletionRequest__courseID_courseIDblockID_blockID(`courseID`, `blockID`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -2583,6 +3245,7 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { public static func getCourseBlocks(courseID: Parameter) -> Verify { return Verify(method: .m_getCourseBlocks__courseID_courseID(`courseID`))} public static func getCourseVideoBlocks(fullStructure: Parameter) -> Verify { return Verify(method: .m_getCourseVideoBlocks__fullStructure_fullStructure(`fullStructure`))} public static func getLoadedCourseBlocks(courseID: Parameter) -> Verify { return Verify(method: .m_getLoadedCourseBlocks__courseID_courseID(`courseID`))} + public static func getSequentialsContainsBlocks(blockIds: Parameter<[String]>, courseID: Parameter) -> Verify { return Verify(method: .m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(`blockIds`, `courseID`))} public static func blockCompletionRequest(courseID: Parameter, blockID: Parameter) -> Verify { return Verify(method: .m_blockCompletionRequest__courseID_courseIDblockID_blockID(`courseID`, `blockID`))} public static func getHandouts(courseID: Parameter) -> Verify { return Verify(method: .m_getHandouts__courseID_courseID(`courseID`))} public static func getUpdates(courseID: Parameter) -> Verify { return Verify(method: .m_getUpdates__courseID_courseID(`courseID`))} @@ -2606,6 +3269,9 @@ open class CourseInteractorProtocolMock: CourseInteractorProtocol, Mock { public static func getLoadedCourseBlocks(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_getLoadedCourseBlocks__courseID_courseID(`courseID`), performs: perform) } + public static func getSequentialsContainsBlocks(blockIds: Parameter<[String]>, courseID: Parameter, perform: @escaping ([String], String) -> Void) -> Perform { + return Perform(method: .m_getSequentialsContainsBlocks__blockIds_blockIdscourseID_courseID(`blockIds`, `courseID`), performs: perform) + } public static func blockCompletionRequest(courseID: Parameter, blockID: Parameter, perform: @escaping (String, String) -> Void) -> Perform { return Perform(method: .m_blockCompletionRequest__courseID_courseIDblockID_blockID(`courseID`, `blockID`), performs: perform) } @@ -2900,6 +3566,20 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] { + addInvocation(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) + let perform = methodPerformValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) as? ([CourseSequential]) -> Void + perform?(`sequentials`) + var __value: [CourseSequential] + do { + __value = try methodReturnValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))).casted() + } catch { + onFatalFailure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + Failure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + } + return __value + } + open func resumeDownloading() throws { addInvocation(.m_resumeDownloading) let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void @@ -2947,6 +3627,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) + case m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case m_removeAppSupportDirectoryUnusedContent @@ -3000,6 +3681,11 @@ 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_updateUnzippedFileSize__for_sequentials(let lhsSequentials), .m_updateUnzippedFileSize__for_sequentials(let rhsSequentials)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSequentials, rhs: rhsSequentials, with: matcher), lhsSequentials, rhsSequentials, "for sequentials")) + return Matcher.ComparisonResult(results) + case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): @@ -3027,6 +3713,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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_updateUnzippedFileSize__for_sequentials(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .m_removeAppSupportDirectoryUnusedContent: return 0 @@ -3047,6 +3734,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + case .m_updateUnzippedFileSize__for_sequentials: return ".updateUnzippedFileSize(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" @@ -3082,6 +3770,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willReturn: [CourseSequential]...) -> MethodStub { + return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willReturn: Bool...) -> MethodStub { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -3120,6 +3811,13 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willProduce: (Stubber<[CourseSequential]>) -> Void) -> MethodStub { + let willReturn: [[CourseSequential]] = [] + let given: Given = { return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseSequential]).self) + willProduce(stubber) + return given + } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [Bool] = [] let given: Given = { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -3204,6 +3902,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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 updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>) -> Verify { return Verify(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`))} 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 func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} @@ -3250,6 +3949,9 @@ 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 updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, perform: @escaping ([CourseSequential]) -> Void) -> Perform { + return Perform(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), performs: perform) + } public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_resumeDownloading, performs: perform) } @@ -3334,6 +4036,205 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } +// MARK: - OfflineSyncInteractorProtocol + +open class OfflineSyncInteractorProtocolMock: OfflineSyncInteractorProtocol, 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 submitOfflineProgress(courseID: String, blockID: String, data: String) throws -> Bool { + addInvocation(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) + let perform = methodPerformValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) as? (String, String, String) -> Void + perform?(`courseID`, `blockID`, `data`) + var __value: Bool + do { + __value = try methodReturnValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + Failure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + } catch { + throw error + } + return __value + } + + + fileprivate enum MethodType { + case m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter, Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let lhsCourseid, let lhsBlockid, let lhsData), .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let rhsCourseid, let rhsBlockid, let rhsData)): + 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: lhsData, rhs: rhsData, with: matcher), lhsData, rhsData, "data")) + return Matcher.ComparisonResult(results) + } + } + + func intValue() -> Int { + switch self { + case let .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + } + } + func assertionName() -> String { + switch self { + case .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data: return ".submitOfflineProgress(courseID:blockID:data:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willReturn: Bool...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Bool).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter) -> Verify { return Verify(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { + return Perform(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), 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 { diff --git a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift index 144614179..46511fb7f 100644 --- a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift @@ -57,7 +57,8 @@ final class CourseContainerViewModelTests: XCTestCase { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ) let vertical = CourseVertical( blockId: "", @@ -388,8 +389,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileLow: nil, hls: nil ), - multiDevice: true - + multiDevice: true, + offlineDownload: nil ) let vertical = CourseVertical( @@ -437,7 +438,7 @@ final class CourseContainerViewModelTests: XCTestCase { )), certificate: nil, org: "", - isSelfPaced: true, + isSelfPaced: true, courseProgress: nil ) @@ -453,15 +454,18 @@ final class CourseContainerViewModelTests: XCTestCase { resumeData: nil, state: .inProgress, type: .video, - fileSize: 1000 + fileSize: 1000, + lastModified: "" ) Given(connectivity, .isInternetAvaliable(getter: true)) Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + Given(connectivity, .isMobileData(getter: false)) Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) Given(downloadManager, .eventPublisher(willReturn: Just(.added).eraseToAnyPublisher())) Given(downloadManager, .getDownloadTasksForCourse(.any, willReturn: [downloadData])) + Given(downloadManager, .updateUnzippedFileSize(for: .any, willReturn: [sequential])) let viewModel = CourseContainerViewModel( interactor: interactor, @@ -483,11 +487,11 @@ final class CourseContainerViewModelTests: XCTestCase { viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates() - await viewModel.onDownloadViewTap( - chapter: chapter, - blockId: blockId, - state: .available - ) + await viewModel.download( + state: .available, + blocks: [block], + sequentials: [sequential] + ) let exp = expectation(description: "Task Starting") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -498,6 +502,7 @@ final class CourseContainerViewModelTests: XCTestCase { XCTAssertEqual(viewModel.sequentialsDownloadState[blockId], .downloading) } + func testOnDownloadViewDownloadingTap() async { let interactor = CourseInteractorProtocolMock() @@ -530,7 +535,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileLow: nil, hls: nil ), - multiDevice: true + multiDevice: true, + offlineDownload: nil ) let vertical = CourseVertical( @@ -584,10 +590,12 @@ final class CourseContainerViewModelTests: XCTestCase { Given(connectivity, .isInternetAvaliable(getter: true)) Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + Given(connectivity, .isMobileData(getter: false)) Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) Given(downloadManager, .eventPublisher(willReturn: Just(.added).eraseToAnyPublisher())) Given(downloadManager, .getDownloadTasksForCourse(.any, willReturn: [])) + Given(downloadManager, .updateUnzippedFileSize(for: .any, willReturn: [])) let viewModel = CourseContainerViewModel( interactor: interactor, @@ -609,11 +617,11 @@ final class CourseContainerViewModelTests: XCTestCase { viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates() - await viewModel.onDownloadViewTap( - chapter: chapter, - blockId: blockId, - state: .downloading - ) + await viewModel.download( + state: .available, + blocks: [block], + sequentials: [sequential] + ) let exp = expectation(description: "Task Starting") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -656,7 +664,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileLow: nil, hls: nil ), - multiDevice: true + multiDevice: true, + offlineDownload: nil ) let vertical = CourseVertical( @@ -710,10 +719,12 @@ final class CourseContainerViewModelTests: XCTestCase { Given(connectivity, .isInternetAvaliable(getter: true)) Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + Given(connectivity, .isMobileData(getter: false)) Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) Given(downloadManager, .eventPublisher(willReturn: Just(.added).eraseToAnyPublisher())) Given(downloadManager, .getDownloadTasksForCourse(.any, willReturn: [])) + Given(downloadManager, .updateUnzippedFileSize(for: .any, willReturn: [])) let viewModel = CourseContainerViewModel( interactor: interactor, @@ -735,11 +746,11 @@ final class CourseContainerViewModelTests: XCTestCase { viewModel.courseStructure = courseStructure await viewModel.setDownloadsStates() - await viewModel.onDownloadViewTap( - chapter: chapter, - blockId: blockId, - state: .finished - ) + await viewModel.download( + state: .available, + blocks: [block], + sequentials: [sequential] + ) let exp = expectation(description: "Task Starting") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -783,7 +794,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileLow: nil, hls: nil ), - multiDevice: true + multiDevice: true, + offlineDownload: nil ) let vertical = CourseVertical( @@ -837,10 +849,12 @@ final class CourseContainerViewModelTests: XCTestCase { Given(connectivity, .isInternetAvaliable(getter: true)) Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + Given(connectivity, .isMobileData(getter: false)) Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) Given(downloadManager, .eventPublisher(willReturn: Just(.added).eraseToAnyPublisher())) Given(downloadManager, .getDownloadTasksForCourse(.any, willReturn: [])) + Given(downloadManager, .updateUnzippedFileSize(for: .any, willReturn: [])) let viewModel = CourseContainerViewModel( interactor: interactor, @@ -903,7 +917,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileLow: nil, hls: nil ), - multiDevice: true + multiDevice: true, + offlineDownload: nil ) let vertical = CourseVertical( @@ -967,15 +982,18 @@ final class CourseContainerViewModelTests: XCTestCase { resumeData: nil, state: .inProgress, type: .video, - fileSize: 1000 + fileSize: 1000, + lastModified: "" ) Given(connectivity, .isInternetAvaliable(getter: true)) Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + Given(connectivity, .isMobileData(getter: false)) Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) Given(downloadManager, .eventPublisher(willReturn: Just(.added).eraseToAnyPublisher())) Given(downloadManager, .getDownloadTasksForCourse(.any, willReturn: [downloadData])) + Given(downloadManager, .updateUnzippedFileSize(for: .any, willReturn: [sequential])) let viewModel = CourseContainerViewModel( interactor: interactor, @@ -1038,7 +1056,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileLow: nil, hls: nil ), - multiDevice: true + multiDevice: true, + offlineDownload: nil ) let vertical = CourseVertical( @@ -1102,15 +1121,18 @@ final class CourseContainerViewModelTests: XCTestCase { resumeData: nil, state: .finished, type: .video, - fileSize: 1000 + fileSize: 1000, + lastModified: "" ) Given(connectivity, .isInternetAvaliable(getter: true)) Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + Given(connectivity, .isMobileData(getter: false)) Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) Given(downloadManager, .eventPublisher(willReturn: Just(.added).eraseToAnyPublisher())) Given(downloadManager, .getDownloadTasksForCourse(.any, willReturn: [downloadData])) + Given(downloadManager, .updateUnzippedFileSize(for: .any, willReturn: [sequential])) let viewModel = CourseContainerViewModel( interactor: interactor, @@ -1172,7 +1194,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileLow: nil, hls: nil ), - multiDevice: true + multiDevice: true, + offlineDownload: nil ) let block2 = CourseBlock( blockId: "123", @@ -1194,7 +1217,8 @@ final class CourseContainerViewModelTests: XCTestCase { mobileLow: nil, hls: nil ), - multiDevice: true + multiDevice: true, + offlineDownload: nil ) let vertical = CourseVertical( @@ -1258,15 +1282,18 @@ final class CourseContainerViewModelTests: XCTestCase { resumeData: nil, state: .finished, type: .video, - fileSize: 1000 + fileSize: 1000, + lastModified: "" ) Given(connectivity, .isInternetAvaliable(getter: true)) Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + Given(connectivity, .isMobileData(getter: false)) Given(downloadManager, .publisher(willReturn: Empty().eraseToAnyPublisher())) Given(downloadManager, .eventPublisher(willReturn: Just(.added).eraseToAnyPublisher())) Given(downloadManager, .getDownloadTasksForCourse(.any, willReturn: [downloadData])) + Given(downloadManager, .updateUnzippedFileSize(for: .any, willReturn: [sequential])) let viewModel = CourseContainerViewModel( interactor: interactor, diff --git a/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift index abf7d2000..dd24288b6 100644 --- a/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift @@ -28,7 +28,8 @@ final class CourseUnitViewModelTests: XCTestCase { studentUrl: "", webUrl: "", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ), CourseBlock(blockId: "2", id: "2", @@ -42,7 +43,8 @@ final class CourseUnitViewModelTests: XCTestCase { studentUrl: "2", webUrl: "2", encodedVideo: nil, - multiDevice: false + multiDevice: false, + offlineDownload: nil ), CourseBlock(blockId: "3", id: "3", @@ -56,7 +58,8 @@ final class CourseUnitViewModelTests: XCTestCase { studentUrl: "3", webUrl: "3", encodedVideo: nil, - multiDevice: true + multiDevice: true, + offlineDownload: nil ), CourseBlock(blockId: "4", id: "4", @@ -70,7 +73,8 @@ final class CourseUnitViewModelTests: XCTestCase { studentUrl: "4", webUrl: "4", encodedVideo: nil, - multiDevice: false + multiDevice: false, + offlineDownload: nil ), ] diff --git a/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents b/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents index 525156723..141c99dbd 100644 --- a/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents +++ b/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents @@ -1,6 +1,6 @@ - + @@ -13,7 +13,7 @@ - + @@ -36,13 +36,13 @@ - + - + @@ -62,4 +62,4 @@ - \ No newline at end of file + diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index 82ae9be00..642eb04fd 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -1466,6 +1466,611 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { } } +// MARK: - CorePersistenceProtocol + +open class CorePersistenceProtocolMock: CorePersistenceProtocol, 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 set(userId: Int) { + addInvocation(.m_set__userId_userId(Parameter.value(`userId`))) + let perform = methodPerformValue(.m_set__userId_userId(Parameter.value(`userId`))) as? (Int) -> Void + perform?(`userId`) + } + + open func getUserID() -> Int? { + addInvocation(.m_getUserID) + let perform = methodPerformValue(.m_getUserID) as? () -> Void + perform?() + var __value: Int? = nil + do { + __value = try methodReturnValue(.m_getUserID).casted() + } catch { + // do nothing + } + return __value + } + + open func publisher() -> AnyPublisher { + addInvocation(.m_publisher) + let perform = methodPerformValue(.m_publisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_publisher).casted() + } catch { + onFatalFailure("Stub return value not specified for publisher(). Use given") + Failure("Stub return value not specified for publisher(). Use given") + } + return __value + } + + open func addToDownloadQueue(tasks: [DownloadDataTask]) { + addInvocation(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) as? ([DownloadDataTask]) -> Void + perform?(`tasks`) + } + + open func saveOfflineProgress(progress: OfflineProgress) { + addInvocation(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) + let perform = methodPerformValue(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) as? (OfflineProgress) -> Void + perform?(`progress`) + } + + open func loadProgress(for blockID: String) -> OfflineProgress? { + addInvocation(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + var __value: OfflineProgress? = nil + do { + __value = try methodReturnValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))).casted() + } catch { + // do nothing + } + return __value + } + + open func loadAllOfflineProgress() -> [OfflineProgress] { + addInvocation(.m_loadAllOfflineProgress) + let perform = methodPerformValue(.m_loadAllOfflineProgress) as? () -> Void + perform?() + var __value: [OfflineProgress] + do { + __value = try methodReturnValue(.m_loadAllOfflineProgress).casted() + } catch { + onFatalFailure("Stub return value not specified for loadAllOfflineProgress(). Use given") + Failure("Stub return value not specified for loadAllOfflineProgress(). Use given") + } + return __value + } + + open func deleteProgress(for blockID: String) { + addInvocation(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + } + + open func deleteAllProgress() { + addInvocation(.m_deleteAllProgress) + let perform = methodPerformValue(.m_deleteAllProgress) as? () -> Void + perform?() + } + + open func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) { + addInvocation(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) as? ([CourseBlock], DownloadQuality) -> Void + perform?(`blocks`, `downloadQuality`) + } + + open func nextBlockForDownloading() -> DownloadDataTask? { + addInvocation(.m_nextBlockForDownloading) + let perform = methodPerformValue(.m_nextBlockForDownloading) as? () -> Void + perform?() + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_nextBlockForDownloading).casted() + } catch { + // do nothing + } + return __value + } + + open func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) { + addInvocation(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) + let perform = methodPerformValue(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) as? (String, DownloadState, Data?) -> Void + perform?(`id`, `state`, `resumeData`) + } + + open func deleteDownloadDataTask(id: String) throws { + addInvocation(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) + let perform = methodPerformValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) as? (String) -> Void + perform?(`id`) + do { + _ = try methodReturnValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func saveDownloadDataTask(_ task: DownloadDataTask) { + addInvocation(.m_saveDownloadDataTask__task(Parameter.value(`task`))) + let perform = methodPerformValue(.m_saveDownloadDataTask__task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void + perform?(`task`) + } + + open func downloadDataTask(for blockId: String) -> DownloadDataTask? { + addInvocation(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void + perform?(`blockId`) + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))).casted() + } catch { + // do nothing + } + return __value + } + + open func getDownloadDataTasks() -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasks) + let perform = methodPerformValue(.m_getDownloadDataTasks) as? () -> Void + perform?() + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasks).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasks(). Use given") + Failure("Stub return value not specified for getDownloadDataTasks(). Use given") + } + return __value + } + + open func getDownloadDataTasksForCourse(_ courseId: String) -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + } + return __value + } + + + fileprivate enum MethodType { + case m_set__userId_userId(Parameter) + case m_getUserID + case m_publisher + case m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>) + case m_saveOfflineProgress__progress_progress(Parameter) + case m_loadProgress__for_blockID(Parameter) + case m_loadAllOfflineProgress + case m_deleteProgress__for_blockID(Parameter) + case m_deleteAllProgress + case m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>, Parameter) + case m_nextBlockForDownloading + case m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter, Parameter, Parameter) + case m_deleteDownloadDataTask__id_id(Parameter) + case m_saveDownloadDataTask__task(Parameter) + case m_downloadDataTask__for_blockId(Parameter) + case m_getDownloadDataTasks + case m_getDownloadDataTasksForCourse__courseId(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_set__userId_userId(let lhsUserid), .m_set__userId_userId(let rhsUserid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUserid, rhs: rhsUserid, with: matcher), lhsUserid, rhsUserid, "userId")) + return Matcher.ComparisonResult(results) + + case (.m_getUserID, .m_getUserID): return .match + + case (.m_publisher, .m_publisher): return .match + + case (.m_addToDownloadQueue__tasks_tasks(let lhsTasks), .m_addToDownloadQueue__tasks_tasks(let rhsTasks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTasks, rhs: rhsTasks, with: matcher), lhsTasks, rhsTasks, "tasks")) + return Matcher.ComparisonResult(results) + + case (.m_saveOfflineProgress__progress_progress(let lhsProgress), .m_saveOfflineProgress__progress_progress(let rhsProgress)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsProgress, rhs: rhsProgress, with: matcher), lhsProgress, rhsProgress, "progress")) + return Matcher.ComparisonResult(results) + + case (.m_loadProgress__for_blockID(let lhsBlockid), .m_loadProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_loadAllOfflineProgress, .m_loadAllOfflineProgress): return .match + + case (.m_deleteProgress__for_blockID(let lhsBlockid), .m_deleteProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_deleteAllProgress, .m_deleteAllProgress): return .match + + case (.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let lhsBlocks, let lhsDownloadquality), .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let rhsBlocks, let rhsDownloadquality)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDownloadquality, rhs: rhsDownloadquality, with: matcher), lhsDownloadquality, rhsDownloadquality, "downloadQuality")) + return Matcher.ComparisonResult(results) + + case (.m_nextBlockForDownloading, .m_nextBlockForDownloading): return .match + + case (.m_updateDownloadState__id_idstate_stateresumeData_resumeData(let lhsId, let lhsState, let lhsResumedata), .m_updateDownloadState__id_idstate_stateresumeData_resumeData(let rhsId, let rhsState, let rhsResumedata)): + 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: lhsState, rhs: rhsState, with: matcher), lhsState, rhsState, "state")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsResumedata, rhs: rhsResumedata, with: matcher), lhsResumedata, rhsResumedata, "resumeData")) + return Matcher.ComparisonResult(results) + + case (.m_deleteDownloadDataTask__id_id(let lhsId), .m_deleteDownloadDataTask__id_id(let rhsId)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "id")) + return Matcher.ComparisonResult(results) + + case (.m_saveDownloadDataTask__task(let lhsTask), .m_saveDownloadDataTask__task(let rhsTask)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "_ task")) + return Matcher.ComparisonResult(results) + + case (.m_downloadDataTask__for_blockId(let lhsBlockid), .m_downloadDataTask__for_blockId(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + return Matcher.ComparisonResult(results) + + case (.m_getDownloadDataTasks, .m_getDownloadDataTasks): return .match + + case (.m_getDownloadDataTasksForCourse__courseId(let lhsCourseid), .m_getDownloadDataTasksForCourse__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 + } + } + + func intValue() -> Int { + switch self { + case let .m_set__userId_userId(p0): return p0.intValue + case .m_getUserID: return 0 + case .m_publisher: return 0 + case let .m_addToDownloadQueue__tasks_tasks(p0): return p0.intValue + case let .m_saveOfflineProgress__progress_progress(p0): return p0.intValue + case let .m_loadProgress__for_blockID(p0): return p0.intValue + case .m_loadAllOfflineProgress: return 0 + case let .m_deleteProgress__for_blockID(p0): return p0.intValue + case .m_deleteAllProgress: return 0 + case let .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(p0, p1): return p0.intValue + p1.intValue + case .m_nextBlockForDownloading: return 0 + case let .m_updateDownloadState__id_idstate_stateresumeData_resumeData(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_deleteDownloadDataTask__id_id(p0): return p0.intValue + case let .m_saveDownloadDataTask__task(p0): return p0.intValue + case let .m_downloadDataTask__for_blockId(p0): return p0.intValue + case .m_getDownloadDataTasks: return 0 + case let .m_getDownloadDataTasksForCourse__courseId(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_set__userId_userId: return ".set(userId:)" + case .m_getUserID: return ".getUserID()" + case .m_publisher: return ".publisher()" + case .m_addToDownloadQueue__tasks_tasks: return ".addToDownloadQueue(tasks:)" + case .m_saveOfflineProgress__progress_progress: return ".saveOfflineProgress(progress:)" + case .m_loadProgress__for_blockID: return ".loadProgress(for:)" + case .m_loadAllOfflineProgress: return ".loadAllOfflineProgress()" + case .m_deleteProgress__for_blockID: return ".deleteProgress(for:)" + case .m_deleteAllProgress: return ".deleteAllProgress()" + case .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality: return ".addToDownloadQueue(blocks:downloadQuality:)" + case .m_nextBlockForDownloading: return ".nextBlockForDownloading()" + case .m_updateDownloadState__id_idstate_stateresumeData_resumeData: return ".updateDownloadState(id:state:resumeData:)" + case .m_deleteDownloadDataTask__id_id: return ".deleteDownloadDataTask(id:)" + case .m_saveDownloadDataTask__task: return ".saveDownloadDataTask(_:)" + case .m_downloadDataTask__for_blockId: return ".downloadDataTask(for:)" + case .m_getDownloadDataTasks: return ".getDownloadDataTasks()" + case .m_getDownloadDataTasksForCourse__courseId: return ".getDownloadDataTasksForCourse(_:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func getUserID(willReturn: Int?...) -> MethodStub { + return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func publisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadProgress(for blockID: Parameter, willReturn: OfflineProgress?...) -> MethodStub { + return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadAllOfflineProgress(willReturn: [OfflineProgress]...) -> MethodStub { + return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func nextBlockForDownloading(willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func downloadDataTask(for blockId: Parameter, willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasks(willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getUserID(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Int?] = [] + let given: Given = { return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Int?).self) + willProduce(stubber) + return given + } + public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func loadProgress(for blockID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [OfflineProgress?] = [] + let given: Given = { return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (OfflineProgress?).self) + willProduce(stubber) + return given + } + public static func loadAllOfflineProgress(willProduce: (Stubber<[OfflineProgress]>) -> Void) -> MethodStub { + let willReturn: [[OfflineProgress]] = [] + let given: Given = { return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([OfflineProgress]).self) + willProduce(stubber) + return given + } + public static func nextBlockForDownloading(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func downloadDataTask(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func deleteDownloadDataTask(id: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func deleteDownloadDataTask(id: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func set(userId: Parameter) -> Verify { return Verify(method: .m_set__userId_userId(`userId`))} + public static func getUserID() -> Verify { return Verify(method: .m_getUserID)} + public static func publisher() -> Verify { return Verify(method: .m_publisher)} + public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>) -> Verify { return Verify(method: .m_addToDownloadQueue__tasks_tasks(`tasks`))} + public static func saveOfflineProgress(progress: Parameter) -> Verify { return Verify(method: .m_saveOfflineProgress__progress_progress(`progress`))} + public static func loadProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_loadProgress__for_blockID(`blockID`))} + public static func loadAllOfflineProgress() -> Verify { return Verify(method: .m_loadAllOfflineProgress)} + public static func deleteProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_deleteProgress__for_blockID(`blockID`))} + public static func deleteAllProgress() -> Verify { return Verify(method: .m_deleteAllProgress)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`))} + public static func nextBlockForDownloading() -> Verify { return Verify(method: .m_nextBlockForDownloading)} + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter) -> Verify { return Verify(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`))} + public static func deleteDownloadDataTask(id: Parameter) -> Verify { return Verify(method: .m_deleteDownloadDataTask__id_id(`id`))} + public static func saveDownloadDataTask(_ task: Parameter) -> Verify { return Verify(method: .m_saveDownloadDataTask__task(`task`))} + public static func downloadDataTask(for blockId: Parameter) -> Verify { return Verify(method: .m_downloadDataTask__for_blockId(`blockId`))} + public static func getDownloadDataTasks() -> Verify { return Verify(method: .m_getDownloadDataTasks)} + public static func getDownloadDataTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func set(userId: Parameter, perform: @escaping (Int) -> Void) -> Perform { + return Perform(method: .m_set__userId_userId(`userId`), performs: perform) + } + public static func getUserID(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getUserID, performs: perform) + } + public static func publisher(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_publisher, performs: perform) + } + public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>, perform: @escaping ([DownloadDataTask]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__tasks_tasks(`tasks`), performs: perform) + } + public static func saveOfflineProgress(progress: Parameter, perform: @escaping (OfflineProgress) -> Void) -> Perform { + return Perform(method: .m_saveOfflineProgress__progress_progress(`progress`), performs: perform) + } + public static func loadProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_loadProgress__for_blockID(`blockID`), performs: perform) + } + public static func loadAllOfflineProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_loadAllOfflineProgress, performs: perform) + } + public static func deleteProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteProgress__for_blockID(`blockID`), performs: perform) + } + public static func deleteAllProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllProgress, performs: perform) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter, perform: @escaping ([CourseBlock], DownloadQuality) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`), performs: perform) + } + public static func nextBlockForDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_nextBlockForDownloading, performs: perform) + } + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter, perform: @escaping (String, DownloadState, Data?) -> Void) -> Perform { + return Perform(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`), performs: perform) + } + public static func deleteDownloadDataTask(id: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteDownloadDataTask__id_id(`id`), performs: perform) + } + public static func saveDownloadDataTask(_ task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { + return Perform(method: .m_saveDownloadDataTask__task(`task`), performs: perform) + } + public static func downloadDataTask(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_downloadDataTask__for_blockId(`blockId`), performs: perform) + } + public static func getDownloadDataTasks(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasks, performs: perform) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), 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 { @@ -2193,6 +2798,20 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] { + addInvocation(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) + let perform = methodPerformValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) as? ([CourseSequential]) -> Void + perform?(`sequentials`) + var __value: [CourseSequential] + do { + __value = try methodReturnValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))).casted() + } catch { + onFatalFailure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + Failure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + } + return __value + } + open func resumeDownloading() throws { addInvocation(.m_resumeDownloading) let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void @@ -2240,6 +2859,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) + case m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case m_removeAppSupportDirectoryUnusedContent @@ -2293,6 +2913,11 @@ 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_updateUnzippedFileSize__for_sequentials(let lhsSequentials), .m_updateUnzippedFileSize__for_sequentials(let rhsSequentials)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSequentials, rhs: rhsSequentials, with: matcher), lhsSequentials, rhsSequentials, "for sequentials")) + return Matcher.ComparisonResult(results) + case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): @@ -2320,6 +2945,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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_updateUnzippedFileSize__for_sequentials(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .m_removeAppSupportDirectoryUnusedContent: return 0 @@ -2340,6 +2966,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + case .m_updateUnzippedFileSize__for_sequentials: return ".updateUnzippedFileSize(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" @@ -2375,6 +3002,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willReturn: [CourseSequential]...) -> MethodStub { + return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willReturn: Bool...) -> MethodStub { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -2413,6 +3043,13 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willProduce: (Stubber<[CourseSequential]>) -> Void) -> MethodStub { + let willReturn: [[CourseSequential]] = [] + let given: Given = { return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseSequential]).self) + willProduce(stubber) + return given + } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [Bool] = [] let given: Given = { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -2497,6 +3134,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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 updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>) -> Verify { return Verify(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`))} 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 func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} @@ -2543,6 +3181,9 @@ 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 updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, perform: @escaping ([CourseSequential]) -> Void) -> Perform { + return Perform(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), performs: perform) + } public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_resumeDownloading, performs: perform) } @@ -2627,6 +3268,205 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } +// MARK: - OfflineSyncInteractorProtocol + +open class OfflineSyncInteractorProtocolMock: OfflineSyncInteractorProtocol, 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 submitOfflineProgress(courseID: String, blockID: String, data: String) throws -> Bool { + addInvocation(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) + let perform = methodPerformValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) as? (String, String, String) -> Void + perform?(`courseID`, `blockID`, `data`) + var __value: Bool + do { + __value = try methodReturnValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + Failure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + } catch { + throw error + } + return __value + } + + + fileprivate enum MethodType { + case m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter, Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let lhsCourseid, let lhsBlockid, let lhsData), .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let rhsCourseid, let rhsBlockid, let rhsData)): + 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: lhsData, rhs: rhsData, with: matcher), lhsData, rhsData, "data")) + return Matcher.ComparisonResult(results) + } + } + + func intValue() -> Int { + switch self { + case let .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + } + } + func assertionName() -> String { + switch self { + case .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data: return ".submitOfflineProgress(courseID:blockID:data:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willReturn: Bool...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Bool).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter) -> Verify { return Verify(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { + return Perform(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), 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 { diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift index b69bb3af9..e4c23ff15 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift @@ -95,11 +95,13 @@ public struct DiscoveryWebview: View { WebView( viewModel: .init( url: URLString, - baseURL: "" + baseURL: "", + openFile: {_ in} ), isLoading: $isLoading, refreshCookies: {}, navigationDelegate: viewModel, + connectivity: viewModel.connectivity, webViewType: discoveryType.rawValue ) .accessibilityIdentifier("discovery_webview") diff --git a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift index a646d5108..52a9ee7de 100644 --- a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift +++ b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift @@ -22,7 +22,7 @@ public struct ProgramWebviewView: View { private var router: DiscoveryRouter private var viewType: ProgramViewType public var pathID: String - + private var URLString: String { switch viewType { case .program: @@ -55,7 +55,8 @@ public struct ProgramWebviewView: View { WebView( viewModel: .init( url: URLString, - baseURL: "", + baseURL: "", + openFile: {_ in}, injections: [.colorInversionCss] ), isLoading: $isLoading, @@ -64,7 +65,8 @@ public struct ProgramWebviewView: View { force: true ) }, - navigationDelegate: viewModel, + navigationDelegate: viewModel, + connectivity: viewModel.connectivity, webViewType: viewType.rawValue ) .accessibilityIdentifier("program_webview") diff --git a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index 1bcdcff78..93a07e4e6 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -1466,6 +1466,611 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { } } +// MARK: - CorePersistenceProtocol + +open class CorePersistenceProtocolMock: CorePersistenceProtocol, 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 set(userId: Int) { + addInvocation(.m_set__userId_userId(Parameter.value(`userId`))) + let perform = methodPerformValue(.m_set__userId_userId(Parameter.value(`userId`))) as? (Int) -> Void + perform?(`userId`) + } + + open func getUserID() -> Int? { + addInvocation(.m_getUserID) + let perform = methodPerformValue(.m_getUserID) as? () -> Void + perform?() + var __value: Int? = nil + do { + __value = try methodReturnValue(.m_getUserID).casted() + } catch { + // do nothing + } + return __value + } + + open func publisher() -> AnyPublisher { + addInvocation(.m_publisher) + let perform = methodPerformValue(.m_publisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_publisher).casted() + } catch { + onFatalFailure("Stub return value not specified for publisher(). Use given") + Failure("Stub return value not specified for publisher(). Use given") + } + return __value + } + + open func addToDownloadQueue(tasks: [DownloadDataTask]) { + addInvocation(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) as? ([DownloadDataTask]) -> Void + perform?(`tasks`) + } + + open func saveOfflineProgress(progress: OfflineProgress) { + addInvocation(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) + let perform = methodPerformValue(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) as? (OfflineProgress) -> Void + perform?(`progress`) + } + + open func loadProgress(for blockID: String) -> OfflineProgress? { + addInvocation(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + var __value: OfflineProgress? = nil + do { + __value = try methodReturnValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))).casted() + } catch { + // do nothing + } + return __value + } + + open func loadAllOfflineProgress() -> [OfflineProgress] { + addInvocation(.m_loadAllOfflineProgress) + let perform = methodPerformValue(.m_loadAllOfflineProgress) as? () -> Void + perform?() + var __value: [OfflineProgress] + do { + __value = try methodReturnValue(.m_loadAllOfflineProgress).casted() + } catch { + onFatalFailure("Stub return value not specified for loadAllOfflineProgress(). Use given") + Failure("Stub return value not specified for loadAllOfflineProgress(). Use given") + } + return __value + } + + open func deleteProgress(for blockID: String) { + addInvocation(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + } + + open func deleteAllProgress() { + addInvocation(.m_deleteAllProgress) + let perform = methodPerformValue(.m_deleteAllProgress) as? () -> Void + perform?() + } + + open func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) { + addInvocation(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) as? ([CourseBlock], DownloadQuality) -> Void + perform?(`blocks`, `downloadQuality`) + } + + open func nextBlockForDownloading() -> DownloadDataTask? { + addInvocation(.m_nextBlockForDownloading) + let perform = methodPerformValue(.m_nextBlockForDownloading) as? () -> Void + perform?() + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_nextBlockForDownloading).casted() + } catch { + // do nothing + } + return __value + } + + open func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) { + addInvocation(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) + let perform = methodPerformValue(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) as? (String, DownloadState, Data?) -> Void + perform?(`id`, `state`, `resumeData`) + } + + open func deleteDownloadDataTask(id: String) throws { + addInvocation(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) + let perform = methodPerformValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) as? (String) -> Void + perform?(`id`) + do { + _ = try methodReturnValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func saveDownloadDataTask(_ task: DownloadDataTask) { + addInvocation(.m_saveDownloadDataTask__task(Parameter.value(`task`))) + let perform = methodPerformValue(.m_saveDownloadDataTask__task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void + perform?(`task`) + } + + open func downloadDataTask(for blockId: String) -> DownloadDataTask? { + addInvocation(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void + perform?(`blockId`) + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))).casted() + } catch { + // do nothing + } + return __value + } + + open func getDownloadDataTasks() -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasks) + let perform = methodPerformValue(.m_getDownloadDataTasks) as? () -> Void + perform?() + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasks).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasks(). Use given") + Failure("Stub return value not specified for getDownloadDataTasks(). Use given") + } + return __value + } + + open func getDownloadDataTasksForCourse(_ courseId: String) -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + } + return __value + } + + + fileprivate enum MethodType { + case m_set__userId_userId(Parameter) + case m_getUserID + case m_publisher + case m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>) + case m_saveOfflineProgress__progress_progress(Parameter) + case m_loadProgress__for_blockID(Parameter) + case m_loadAllOfflineProgress + case m_deleteProgress__for_blockID(Parameter) + case m_deleteAllProgress + case m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>, Parameter) + case m_nextBlockForDownloading + case m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter, Parameter, Parameter) + case m_deleteDownloadDataTask__id_id(Parameter) + case m_saveDownloadDataTask__task(Parameter) + case m_downloadDataTask__for_blockId(Parameter) + case m_getDownloadDataTasks + case m_getDownloadDataTasksForCourse__courseId(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_set__userId_userId(let lhsUserid), .m_set__userId_userId(let rhsUserid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUserid, rhs: rhsUserid, with: matcher), lhsUserid, rhsUserid, "userId")) + return Matcher.ComparisonResult(results) + + case (.m_getUserID, .m_getUserID): return .match + + case (.m_publisher, .m_publisher): return .match + + case (.m_addToDownloadQueue__tasks_tasks(let lhsTasks), .m_addToDownloadQueue__tasks_tasks(let rhsTasks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTasks, rhs: rhsTasks, with: matcher), lhsTasks, rhsTasks, "tasks")) + return Matcher.ComparisonResult(results) + + case (.m_saveOfflineProgress__progress_progress(let lhsProgress), .m_saveOfflineProgress__progress_progress(let rhsProgress)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsProgress, rhs: rhsProgress, with: matcher), lhsProgress, rhsProgress, "progress")) + return Matcher.ComparisonResult(results) + + case (.m_loadProgress__for_blockID(let lhsBlockid), .m_loadProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_loadAllOfflineProgress, .m_loadAllOfflineProgress): return .match + + case (.m_deleteProgress__for_blockID(let lhsBlockid), .m_deleteProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_deleteAllProgress, .m_deleteAllProgress): return .match + + case (.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let lhsBlocks, let lhsDownloadquality), .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let rhsBlocks, let rhsDownloadquality)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDownloadquality, rhs: rhsDownloadquality, with: matcher), lhsDownloadquality, rhsDownloadquality, "downloadQuality")) + return Matcher.ComparisonResult(results) + + case (.m_nextBlockForDownloading, .m_nextBlockForDownloading): return .match + + case (.m_updateDownloadState__id_idstate_stateresumeData_resumeData(let lhsId, let lhsState, let lhsResumedata), .m_updateDownloadState__id_idstate_stateresumeData_resumeData(let rhsId, let rhsState, let rhsResumedata)): + 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: lhsState, rhs: rhsState, with: matcher), lhsState, rhsState, "state")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsResumedata, rhs: rhsResumedata, with: matcher), lhsResumedata, rhsResumedata, "resumeData")) + return Matcher.ComparisonResult(results) + + case (.m_deleteDownloadDataTask__id_id(let lhsId), .m_deleteDownloadDataTask__id_id(let rhsId)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "id")) + return Matcher.ComparisonResult(results) + + case (.m_saveDownloadDataTask__task(let lhsTask), .m_saveDownloadDataTask__task(let rhsTask)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "_ task")) + return Matcher.ComparisonResult(results) + + case (.m_downloadDataTask__for_blockId(let lhsBlockid), .m_downloadDataTask__for_blockId(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + return Matcher.ComparisonResult(results) + + case (.m_getDownloadDataTasks, .m_getDownloadDataTasks): return .match + + case (.m_getDownloadDataTasksForCourse__courseId(let lhsCourseid), .m_getDownloadDataTasksForCourse__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 + } + } + + func intValue() -> Int { + switch self { + case let .m_set__userId_userId(p0): return p0.intValue + case .m_getUserID: return 0 + case .m_publisher: return 0 + case let .m_addToDownloadQueue__tasks_tasks(p0): return p0.intValue + case let .m_saveOfflineProgress__progress_progress(p0): return p0.intValue + case let .m_loadProgress__for_blockID(p0): return p0.intValue + case .m_loadAllOfflineProgress: return 0 + case let .m_deleteProgress__for_blockID(p0): return p0.intValue + case .m_deleteAllProgress: return 0 + case let .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(p0, p1): return p0.intValue + p1.intValue + case .m_nextBlockForDownloading: return 0 + case let .m_updateDownloadState__id_idstate_stateresumeData_resumeData(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_deleteDownloadDataTask__id_id(p0): return p0.intValue + case let .m_saveDownloadDataTask__task(p0): return p0.intValue + case let .m_downloadDataTask__for_blockId(p0): return p0.intValue + case .m_getDownloadDataTasks: return 0 + case let .m_getDownloadDataTasksForCourse__courseId(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_set__userId_userId: return ".set(userId:)" + case .m_getUserID: return ".getUserID()" + case .m_publisher: return ".publisher()" + case .m_addToDownloadQueue__tasks_tasks: return ".addToDownloadQueue(tasks:)" + case .m_saveOfflineProgress__progress_progress: return ".saveOfflineProgress(progress:)" + case .m_loadProgress__for_blockID: return ".loadProgress(for:)" + case .m_loadAllOfflineProgress: return ".loadAllOfflineProgress()" + case .m_deleteProgress__for_blockID: return ".deleteProgress(for:)" + case .m_deleteAllProgress: return ".deleteAllProgress()" + case .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality: return ".addToDownloadQueue(blocks:downloadQuality:)" + case .m_nextBlockForDownloading: return ".nextBlockForDownloading()" + case .m_updateDownloadState__id_idstate_stateresumeData_resumeData: return ".updateDownloadState(id:state:resumeData:)" + case .m_deleteDownloadDataTask__id_id: return ".deleteDownloadDataTask(id:)" + case .m_saveDownloadDataTask__task: return ".saveDownloadDataTask(_:)" + case .m_downloadDataTask__for_blockId: return ".downloadDataTask(for:)" + case .m_getDownloadDataTasks: return ".getDownloadDataTasks()" + case .m_getDownloadDataTasksForCourse__courseId: return ".getDownloadDataTasksForCourse(_:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func getUserID(willReturn: Int?...) -> MethodStub { + return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func publisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadProgress(for blockID: Parameter, willReturn: OfflineProgress?...) -> MethodStub { + return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadAllOfflineProgress(willReturn: [OfflineProgress]...) -> MethodStub { + return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func nextBlockForDownloading(willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func downloadDataTask(for blockId: Parameter, willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasks(willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getUserID(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Int?] = [] + let given: Given = { return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Int?).self) + willProduce(stubber) + return given + } + public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func loadProgress(for blockID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [OfflineProgress?] = [] + let given: Given = { return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (OfflineProgress?).self) + willProduce(stubber) + return given + } + public static func loadAllOfflineProgress(willProduce: (Stubber<[OfflineProgress]>) -> Void) -> MethodStub { + let willReturn: [[OfflineProgress]] = [] + let given: Given = { return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([OfflineProgress]).self) + willProduce(stubber) + return given + } + public static func nextBlockForDownloading(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func downloadDataTask(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func deleteDownloadDataTask(id: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func deleteDownloadDataTask(id: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func set(userId: Parameter) -> Verify { return Verify(method: .m_set__userId_userId(`userId`))} + public static func getUserID() -> Verify { return Verify(method: .m_getUserID)} + public static func publisher() -> Verify { return Verify(method: .m_publisher)} + public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>) -> Verify { return Verify(method: .m_addToDownloadQueue__tasks_tasks(`tasks`))} + public static func saveOfflineProgress(progress: Parameter) -> Verify { return Verify(method: .m_saveOfflineProgress__progress_progress(`progress`))} + public static func loadProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_loadProgress__for_blockID(`blockID`))} + public static func loadAllOfflineProgress() -> Verify { return Verify(method: .m_loadAllOfflineProgress)} + public static func deleteProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_deleteProgress__for_blockID(`blockID`))} + public static func deleteAllProgress() -> Verify { return Verify(method: .m_deleteAllProgress)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`))} + public static func nextBlockForDownloading() -> Verify { return Verify(method: .m_nextBlockForDownloading)} + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter) -> Verify { return Verify(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`))} + public static func deleteDownloadDataTask(id: Parameter) -> Verify { return Verify(method: .m_deleteDownloadDataTask__id_id(`id`))} + public static func saveDownloadDataTask(_ task: Parameter) -> Verify { return Verify(method: .m_saveDownloadDataTask__task(`task`))} + public static func downloadDataTask(for blockId: Parameter) -> Verify { return Verify(method: .m_downloadDataTask__for_blockId(`blockId`))} + public static func getDownloadDataTasks() -> Verify { return Verify(method: .m_getDownloadDataTasks)} + public static func getDownloadDataTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func set(userId: Parameter, perform: @escaping (Int) -> Void) -> Perform { + return Perform(method: .m_set__userId_userId(`userId`), performs: perform) + } + public static func getUserID(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getUserID, performs: perform) + } + public static func publisher(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_publisher, performs: perform) + } + public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>, perform: @escaping ([DownloadDataTask]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__tasks_tasks(`tasks`), performs: perform) + } + public static func saveOfflineProgress(progress: Parameter, perform: @escaping (OfflineProgress) -> Void) -> Perform { + return Perform(method: .m_saveOfflineProgress__progress_progress(`progress`), performs: perform) + } + public static func loadProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_loadProgress__for_blockID(`blockID`), performs: perform) + } + public static func loadAllOfflineProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_loadAllOfflineProgress, performs: perform) + } + public static func deleteProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteProgress__for_blockID(`blockID`), performs: perform) + } + public static func deleteAllProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllProgress, performs: perform) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter, perform: @escaping ([CourseBlock], DownloadQuality) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`), performs: perform) + } + public static func nextBlockForDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_nextBlockForDownloading, performs: perform) + } + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter, perform: @escaping (String, DownloadState, Data?) -> Void) -> Perform { + return Perform(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`), performs: perform) + } + public static func deleteDownloadDataTask(id: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteDownloadDataTask__id_id(`id`), performs: perform) + } + public static func saveDownloadDataTask(_ task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { + return Perform(method: .m_saveDownloadDataTask__task(`task`), performs: perform) + } + public static func downloadDataTask(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_downloadDataTask__for_blockId(`blockId`), performs: perform) + } + public static func getDownloadDataTasks(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasks, performs: perform) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), 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 { @@ -2387,6 +2992,20 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] { + addInvocation(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) + let perform = methodPerformValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) as? ([CourseSequential]) -> Void + perform?(`sequentials`) + var __value: [CourseSequential] + do { + __value = try methodReturnValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))).casted() + } catch { + onFatalFailure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + Failure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + } + return __value + } + open func resumeDownloading() throws { addInvocation(.m_resumeDownloading) let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void @@ -2434,6 +3053,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) + case m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case m_removeAppSupportDirectoryUnusedContent @@ -2487,6 +3107,11 @@ 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_updateUnzippedFileSize__for_sequentials(let lhsSequentials), .m_updateUnzippedFileSize__for_sequentials(let rhsSequentials)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSequentials, rhs: rhsSequentials, with: matcher), lhsSequentials, rhsSequentials, "for sequentials")) + return Matcher.ComparisonResult(results) + case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): @@ -2514,6 +3139,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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_updateUnzippedFileSize__for_sequentials(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .m_removeAppSupportDirectoryUnusedContent: return 0 @@ -2534,6 +3160,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + case .m_updateUnzippedFileSize__for_sequentials: return ".updateUnzippedFileSize(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" @@ -2569,6 +3196,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willReturn: [CourseSequential]...) -> MethodStub { + return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willReturn: Bool...) -> MethodStub { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -2607,6 +3237,13 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willProduce: (Stubber<[CourseSequential]>) -> Void) -> MethodStub { + let willReturn: [[CourseSequential]] = [] + let given: Given = { return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseSequential]).self) + willProduce(stubber) + return given + } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [Bool] = [] let given: Given = { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -2691,6 +3328,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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 updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>) -> Verify { return Verify(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`))} 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 func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} @@ -2737,6 +3375,9 @@ 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 updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, perform: @escaping ([CourseSequential]) -> Void) -> Perform { + return Perform(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), performs: perform) + } public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_resumeDownloading, performs: perform) } @@ -2821,6 +3462,205 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } +// MARK: - OfflineSyncInteractorProtocol + +open class OfflineSyncInteractorProtocolMock: OfflineSyncInteractorProtocol, 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 submitOfflineProgress(courseID: String, blockID: String, data: String) throws -> Bool { + addInvocation(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) + let perform = methodPerformValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) as? (String, String, String) -> Void + perform?(`courseID`, `blockID`, `data`) + var __value: Bool + do { + __value = try methodReturnValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + Failure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + } catch { + throw error + } + return __value + } + + + fileprivate enum MethodType { + case m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter, Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let lhsCourseid, let lhsBlockid, let lhsData), .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let rhsCourseid, let rhsBlockid, let rhsData)): + 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: lhsData, rhs: rhsData, with: matcher), lhsData, rhsData, "data")) + return Matcher.ComparisonResult(results) + } + } + + func intValue() -> Int { + switch self { + case let .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + } + } + func assertionName() -> String { + switch self { + case .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data: return ".submitOfflineProgress(courseID:blockID:data:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willReturn: Bool...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Bool).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter) -> Verify { return Verify(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { + return Perform(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), 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 { diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index 82022c6aa..758b1cbdf 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -1466,6 +1466,611 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { } } +// MARK: - CorePersistenceProtocol + +open class CorePersistenceProtocolMock: CorePersistenceProtocol, 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 set(userId: Int) { + addInvocation(.m_set__userId_userId(Parameter.value(`userId`))) + let perform = methodPerformValue(.m_set__userId_userId(Parameter.value(`userId`))) as? (Int) -> Void + perform?(`userId`) + } + + open func getUserID() -> Int? { + addInvocation(.m_getUserID) + let perform = methodPerformValue(.m_getUserID) as? () -> Void + perform?() + var __value: Int? = nil + do { + __value = try methodReturnValue(.m_getUserID).casted() + } catch { + // do nothing + } + return __value + } + + open func publisher() -> AnyPublisher { + addInvocation(.m_publisher) + let perform = methodPerformValue(.m_publisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_publisher).casted() + } catch { + onFatalFailure("Stub return value not specified for publisher(). Use given") + Failure("Stub return value not specified for publisher(). Use given") + } + return __value + } + + open func addToDownloadQueue(tasks: [DownloadDataTask]) { + addInvocation(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) as? ([DownloadDataTask]) -> Void + perform?(`tasks`) + } + + open func saveOfflineProgress(progress: OfflineProgress) { + addInvocation(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) + let perform = methodPerformValue(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) as? (OfflineProgress) -> Void + perform?(`progress`) + } + + open func loadProgress(for blockID: String) -> OfflineProgress? { + addInvocation(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + var __value: OfflineProgress? = nil + do { + __value = try methodReturnValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))).casted() + } catch { + // do nothing + } + return __value + } + + open func loadAllOfflineProgress() -> [OfflineProgress] { + addInvocation(.m_loadAllOfflineProgress) + let perform = methodPerformValue(.m_loadAllOfflineProgress) as? () -> Void + perform?() + var __value: [OfflineProgress] + do { + __value = try methodReturnValue(.m_loadAllOfflineProgress).casted() + } catch { + onFatalFailure("Stub return value not specified for loadAllOfflineProgress(). Use given") + Failure("Stub return value not specified for loadAllOfflineProgress(). Use given") + } + return __value + } + + open func deleteProgress(for blockID: String) { + addInvocation(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + } + + open func deleteAllProgress() { + addInvocation(.m_deleteAllProgress) + let perform = methodPerformValue(.m_deleteAllProgress) as? () -> Void + perform?() + } + + open func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) { + addInvocation(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) as? ([CourseBlock], DownloadQuality) -> Void + perform?(`blocks`, `downloadQuality`) + } + + open func nextBlockForDownloading() -> DownloadDataTask? { + addInvocation(.m_nextBlockForDownloading) + let perform = methodPerformValue(.m_nextBlockForDownloading) as? () -> Void + perform?() + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_nextBlockForDownloading).casted() + } catch { + // do nothing + } + return __value + } + + open func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) { + addInvocation(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) + let perform = methodPerformValue(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) as? (String, DownloadState, Data?) -> Void + perform?(`id`, `state`, `resumeData`) + } + + open func deleteDownloadDataTask(id: String) throws { + addInvocation(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) + let perform = methodPerformValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) as? (String) -> Void + perform?(`id`) + do { + _ = try methodReturnValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func saveDownloadDataTask(_ task: DownloadDataTask) { + addInvocation(.m_saveDownloadDataTask__task(Parameter.value(`task`))) + let perform = methodPerformValue(.m_saveDownloadDataTask__task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void + perform?(`task`) + } + + open func downloadDataTask(for blockId: String) -> DownloadDataTask? { + addInvocation(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void + perform?(`blockId`) + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))).casted() + } catch { + // do nothing + } + return __value + } + + open func getDownloadDataTasks() -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasks) + let perform = methodPerformValue(.m_getDownloadDataTasks) as? () -> Void + perform?() + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasks).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasks(). Use given") + Failure("Stub return value not specified for getDownloadDataTasks(). Use given") + } + return __value + } + + open func getDownloadDataTasksForCourse(_ courseId: String) -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + } + return __value + } + + + fileprivate enum MethodType { + case m_set__userId_userId(Parameter) + case m_getUserID + case m_publisher + case m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>) + case m_saveOfflineProgress__progress_progress(Parameter) + case m_loadProgress__for_blockID(Parameter) + case m_loadAllOfflineProgress + case m_deleteProgress__for_blockID(Parameter) + case m_deleteAllProgress + case m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>, Parameter) + case m_nextBlockForDownloading + case m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter, Parameter, Parameter) + case m_deleteDownloadDataTask__id_id(Parameter) + case m_saveDownloadDataTask__task(Parameter) + case m_downloadDataTask__for_blockId(Parameter) + case m_getDownloadDataTasks + case m_getDownloadDataTasksForCourse__courseId(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_set__userId_userId(let lhsUserid), .m_set__userId_userId(let rhsUserid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUserid, rhs: rhsUserid, with: matcher), lhsUserid, rhsUserid, "userId")) + return Matcher.ComparisonResult(results) + + case (.m_getUserID, .m_getUserID): return .match + + case (.m_publisher, .m_publisher): return .match + + case (.m_addToDownloadQueue__tasks_tasks(let lhsTasks), .m_addToDownloadQueue__tasks_tasks(let rhsTasks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTasks, rhs: rhsTasks, with: matcher), lhsTasks, rhsTasks, "tasks")) + return Matcher.ComparisonResult(results) + + case (.m_saveOfflineProgress__progress_progress(let lhsProgress), .m_saveOfflineProgress__progress_progress(let rhsProgress)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsProgress, rhs: rhsProgress, with: matcher), lhsProgress, rhsProgress, "progress")) + return Matcher.ComparisonResult(results) + + case (.m_loadProgress__for_blockID(let lhsBlockid), .m_loadProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_loadAllOfflineProgress, .m_loadAllOfflineProgress): return .match + + case (.m_deleteProgress__for_blockID(let lhsBlockid), .m_deleteProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_deleteAllProgress, .m_deleteAllProgress): return .match + + case (.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let lhsBlocks, let lhsDownloadquality), .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let rhsBlocks, let rhsDownloadquality)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDownloadquality, rhs: rhsDownloadquality, with: matcher), lhsDownloadquality, rhsDownloadquality, "downloadQuality")) + return Matcher.ComparisonResult(results) + + case (.m_nextBlockForDownloading, .m_nextBlockForDownloading): return .match + + case (.m_updateDownloadState__id_idstate_stateresumeData_resumeData(let lhsId, let lhsState, let lhsResumedata), .m_updateDownloadState__id_idstate_stateresumeData_resumeData(let rhsId, let rhsState, let rhsResumedata)): + 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: lhsState, rhs: rhsState, with: matcher), lhsState, rhsState, "state")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsResumedata, rhs: rhsResumedata, with: matcher), lhsResumedata, rhsResumedata, "resumeData")) + return Matcher.ComparisonResult(results) + + case (.m_deleteDownloadDataTask__id_id(let lhsId), .m_deleteDownloadDataTask__id_id(let rhsId)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "id")) + return Matcher.ComparisonResult(results) + + case (.m_saveDownloadDataTask__task(let lhsTask), .m_saveDownloadDataTask__task(let rhsTask)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "_ task")) + return Matcher.ComparisonResult(results) + + case (.m_downloadDataTask__for_blockId(let lhsBlockid), .m_downloadDataTask__for_blockId(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + return Matcher.ComparisonResult(results) + + case (.m_getDownloadDataTasks, .m_getDownloadDataTasks): return .match + + case (.m_getDownloadDataTasksForCourse__courseId(let lhsCourseid), .m_getDownloadDataTasksForCourse__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 + } + } + + func intValue() -> Int { + switch self { + case let .m_set__userId_userId(p0): return p0.intValue + case .m_getUserID: return 0 + case .m_publisher: return 0 + case let .m_addToDownloadQueue__tasks_tasks(p0): return p0.intValue + case let .m_saveOfflineProgress__progress_progress(p0): return p0.intValue + case let .m_loadProgress__for_blockID(p0): return p0.intValue + case .m_loadAllOfflineProgress: return 0 + case let .m_deleteProgress__for_blockID(p0): return p0.intValue + case .m_deleteAllProgress: return 0 + case let .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(p0, p1): return p0.intValue + p1.intValue + case .m_nextBlockForDownloading: return 0 + case let .m_updateDownloadState__id_idstate_stateresumeData_resumeData(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_deleteDownloadDataTask__id_id(p0): return p0.intValue + case let .m_saveDownloadDataTask__task(p0): return p0.intValue + case let .m_downloadDataTask__for_blockId(p0): return p0.intValue + case .m_getDownloadDataTasks: return 0 + case let .m_getDownloadDataTasksForCourse__courseId(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_set__userId_userId: return ".set(userId:)" + case .m_getUserID: return ".getUserID()" + case .m_publisher: return ".publisher()" + case .m_addToDownloadQueue__tasks_tasks: return ".addToDownloadQueue(tasks:)" + case .m_saveOfflineProgress__progress_progress: return ".saveOfflineProgress(progress:)" + case .m_loadProgress__for_blockID: return ".loadProgress(for:)" + case .m_loadAllOfflineProgress: return ".loadAllOfflineProgress()" + case .m_deleteProgress__for_blockID: return ".deleteProgress(for:)" + case .m_deleteAllProgress: return ".deleteAllProgress()" + case .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality: return ".addToDownloadQueue(blocks:downloadQuality:)" + case .m_nextBlockForDownloading: return ".nextBlockForDownloading()" + case .m_updateDownloadState__id_idstate_stateresumeData_resumeData: return ".updateDownloadState(id:state:resumeData:)" + case .m_deleteDownloadDataTask__id_id: return ".deleteDownloadDataTask(id:)" + case .m_saveDownloadDataTask__task: return ".saveDownloadDataTask(_:)" + case .m_downloadDataTask__for_blockId: return ".downloadDataTask(for:)" + case .m_getDownloadDataTasks: return ".getDownloadDataTasks()" + case .m_getDownloadDataTasksForCourse__courseId: return ".getDownloadDataTasksForCourse(_:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func getUserID(willReturn: Int?...) -> MethodStub { + return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func publisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadProgress(for blockID: Parameter, willReturn: OfflineProgress?...) -> MethodStub { + return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadAllOfflineProgress(willReturn: [OfflineProgress]...) -> MethodStub { + return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func nextBlockForDownloading(willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func downloadDataTask(for blockId: Parameter, willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasks(willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getUserID(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Int?] = [] + let given: Given = { return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Int?).self) + willProduce(stubber) + return given + } + public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func loadProgress(for blockID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [OfflineProgress?] = [] + let given: Given = { return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (OfflineProgress?).self) + willProduce(stubber) + return given + } + public static func loadAllOfflineProgress(willProduce: (Stubber<[OfflineProgress]>) -> Void) -> MethodStub { + let willReturn: [[OfflineProgress]] = [] + let given: Given = { return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([OfflineProgress]).self) + willProduce(stubber) + return given + } + public static func nextBlockForDownloading(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func downloadDataTask(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func deleteDownloadDataTask(id: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func deleteDownloadDataTask(id: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func set(userId: Parameter) -> Verify { return Verify(method: .m_set__userId_userId(`userId`))} + public static func getUserID() -> Verify { return Verify(method: .m_getUserID)} + public static func publisher() -> Verify { return Verify(method: .m_publisher)} + public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>) -> Verify { return Verify(method: .m_addToDownloadQueue__tasks_tasks(`tasks`))} + public static func saveOfflineProgress(progress: Parameter) -> Verify { return Verify(method: .m_saveOfflineProgress__progress_progress(`progress`))} + public static func loadProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_loadProgress__for_blockID(`blockID`))} + public static func loadAllOfflineProgress() -> Verify { return Verify(method: .m_loadAllOfflineProgress)} + public static func deleteProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_deleteProgress__for_blockID(`blockID`))} + public static func deleteAllProgress() -> Verify { return Verify(method: .m_deleteAllProgress)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`))} + public static func nextBlockForDownloading() -> Verify { return Verify(method: .m_nextBlockForDownloading)} + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter) -> Verify { return Verify(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`))} + public static func deleteDownloadDataTask(id: Parameter) -> Verify { return Verify(method: .m_deleteDownloadDataTask__id_id(`id`))} + public static func saveDownloadDataTask(_ task: Parameter) -> Verify { return Verify(method: .m_saveDownloadDataTask__task(`task`))} + public static func downloadDataTask(for blockId: Parameter) -> Verify { return Verify(method: .m_downloadDataTask__for_blockId(`blockId`))} + public static func getDownloadDataTasks() -> Verify { return Verify(method: .m_getDownloadDataTasks)} + public static func getDownloadDataTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func set(userId: Parameter, perform: @escaping (Int) -> Void) -> Perform { + return Perform(method: .m_set__userId_userId(`userId`), performs: perform) + } + public static func getUserID(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getUserID, performs: perform) + } + public static func publisher(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_publisher, performs: perform) + } + public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>, perform: @escaping ([DownloadDataTask]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__tasks_tasks(`tasks`), performs: perform) + } + public static func saveOfflineProgress(progress: Parameter, perform: @escaping (OfflineProgress) -> Void) -> Perform { + return Perform(method: .m_saveOfflineProgress__progress_progress(`progress`), performs: perform) + } + public static func loadProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_loadProgress__for_blockID(`blockID`), performs: perform) + } + public static func loadAllOfflineProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_loadAllOfflineProgress, performs: perform) + } + public static func deleteProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteProgress__for_blockID(`blockID`), performs: perform) + } + public static func deleteAllProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllProgress, performs: perform) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter, perform: @escaping ([CourseBlock], DownloadQuality) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`), performs: perform) + } + public static func nextBlockForDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_nextBlockForDownloading, performs: perform) + } + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter, perform: @escaping (String, DownloadState, Data?) -> Void) -> Perform { + return Perform(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`), performs: perform) + } + public static func deleteDownloadDataTask(id: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteDownloadDataTask__id_id(`id`), performs: perform) + } + public static func saveDownloadDataTask(_ task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { + return Perform(method: .m_saveDownloadDataTask__task(`task`), performs: perform) + } + public static func downloadDataTask(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_downloadDataTask__for_blockId(`blockId`), performs: perform) + } + public static func getDownloadDataTasks(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasks, performs: perform) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), 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 { @@ -3324,6 +3929,20 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] { + addInvocation(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) + let perform = methodPerformValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) as? ([CourseSequential]) -> Void + perform?(`sequentials`) + var __value: [CourseSequential] + do { + __value = try methodReturnValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))).casted() + } catch { + onFatalFailure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + Failure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + } + return __value + } + open func resumeDownloading() throws { addInvocation(.m_resumeDownloading) let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void @@ -3371,6 +3990,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) + case m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case m_removeAppSupportDirectoryUnusedContent @@ -3424,6 +4044,11 @@ 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_updateUnzippedFileSize__for_sequentials(let lhsSequentials), .m_updateUnzippedFileSize__for_sequentials(let rhsSequentials)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSequentials, rhs: rhsSequentials, with: matcher), lhsSequentials, rhsSequentials, "for sequentials")) + return Matcher.ComparisonResult(results) + case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): @@ -3451,6 +4076,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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_updateUnzippedFileSize__for_sequentials(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .m_removeAppSupportDirectoryUnusedContent: return 0 @@ -3471,6 +4097,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + case .m_updateUnzippedFileSize__for_sequentials: return ".updateUnzippedFileSize(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" @@ -3506,6 +4133,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willReturn: [CourseSequential]...) -> MethodStub { + return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willReturn: Bool...) -> MethodStub { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -3544,6 +4174,13 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willProduce: (Stubber<[CourseSequential]>) -> Void) -> MethodStub { + let willReturn: [[CourseSequential]] = [] + let given: Given = { return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseSequential]).self) + willProduce(stubber) + return given + } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [Bool] = [] let given: Given = { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -3628,6 +4265,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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 updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>) -> Verify { return Verify(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`))} 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 func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} @@ -3674,6 +4312,9 @@ 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 updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, perform: @escaping ([CourseSequential]) -> Void) -> Perform { + return Perform(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), performs: perform) + } public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_resumeDownloading, performs: perform) } @@ -3758,6 +4399,205 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } +// MARK: - OfflineSyncInteractorProtocol + +open class OfflineSyncInteractorProtocolMock: OfflineSyncInteractorProtocol, 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 submitOfflineProgress(courseID: String, blockID: String, data: String) throws -> Bool { + addInvocation(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) + let perform = methodPerformValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) as? (String, String, String) -> Void + perform?(`courseID`, `blockID`, `data`) + var __value: Bool + do { + __value = try methodReturnValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + Failure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + } catch { + throw error + } + return __value + } + + + fileprivate enum MethodType { + case m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter, Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let lhsCourseid, let lhsBlockid, let lhsData), .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let rhsCourseid, let rhsBlockid, let rhsData)): + 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: lhsData, rhs: rhsData, with: matcher), lhsData, rhsData, "data")) + return Matcher.ComparisonResult(results) + } + } + + func intValue() -> Int { + switch self { + case let .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + } + } + func assertionName() -> String { + switch self { + case .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data: return ".submitOfflineProgress(courseID:blockID:data:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willReturn: Bool...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Bool).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter) -> Verify { return Verify(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { + return Perform(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), 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 { diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 653283d3c..dca913869 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -762,7 +762,9 @@ FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; @@ -851,7 +853,9 @@ FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; @@ -946,7 +950,9 @@ FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; @@ -1035,7 +1041,9 @@ FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; @@ -1184,7 +1192,9 @@ FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; @@ -1219,7 +1229,9 @@ FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; + INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index 36cea0ba7..f21e60633 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -16,10 +16,13 @@ import UserNotifications import FirebaseCore import FirebaseMessaging import Theme +import BackgroundTasks @UIApplicationMain class AppDelegate: UIResponder, UIApplicationDelegate { + static let bgAppTaskId = "openEdx.offlineProgressSync" + static var shared: AppDelegate { UIApplication.shared.delegate as! AppDelegate } @@ -157,6 +160,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { lastForceLogoutTime = Date().timeIntervalSince1970 Container.shared.resolve(CoreStorage.self)?.clear() + Container.shared.resolve(CorePersistenceProtocol.self)?.deleteAllProgress() Task { await Container.shared.resolve(DownloadManagerProtocol.self)?.deleteAllFiles() } @@ -195,4 +199,46 @@ class AppDelegate: UIResponder, UIApplicationDelegate { guard let deepLinkManager = Container.shared.resolve(DeepLinkManager.self) else { return } deepLinkManager.configureDeepLinkService(launchOptions: launchOptions) } + + // Background progress update + + func registerBackgroundTask() { + let isRegistered = BGTaskScheduler.shared.register( + forTaskWithIdentifier: Self.bgAppTaskId, + using: nil + ) { task in + debugLog("Background task is executing: \(task.identifier)") + guard let task = task as? BGAppRefreshTask else { return } + self.handleAppRefreshTask(task: task) + } + debugLog("Is the background task registered? \(isRegistered)") + } + + func handleAppRefreshTask(task: BGAppRefreshTask) { + //In real case scenario we should check internet here + reScheduleAppRefresh() + + task.expirationHandler = { + //This Block call by System + //Canel your all tak's & queues + task.setTaskCompleted(success: true) + } + + let offlineSyncManager = Container.shared.resolve(OfflineSyncManagerProtocol.self)! + Task { + await offlineSyncManager.syncOfflineProgress() + task.setTaskCompleted(success: true) + } + } + + func reScheduleAppRefresh() { + let request = BGAppRefreshTaskRequest(identifier: Self.bgAppTaskId) + request.earliestBeginDate = Date(timeIntervalSinceNow: 60 * 60) // App Refresh after 60 minute. + //Note :: EarliestBeginDate should not be set to too far into the future. + do { + try BGTaskScheduler.shared.submit(request) + } catch { + debugLog("Could not schedule app refresh: \(error)") + } + } } diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 578e94df6..a65f25833 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -20,6 +20,26 @@ import Combine class ScreenAssembly: Assembly { func assemble(container: Container) { + // MARK: OfflineSync + container.register(OfflineSyncRepositoryProtocol.self) { r in + OfflineSyncRepository( + api: r.resolve(API.self)! + ) + } + container.register(OfflineSyncInteractorProtocol.self) { r in + OfflineSyncInteractor( + repository: r.resolve(OfflineSyncRepositoryProtocol.self)! + ) + } + + container.register(OfflineSyncManagerProtocol.self) { r in + OfflineSyncManager( + persistence: r.resolve(CorePersistenceProtocol.self)!, + interactor: r.resolve(OfflineSyncInteractorProtocol.self)!, + connectivity: r.resolve(ConnectivityProtocol.self)! + ) + } + // MARK: Auth container.register(AuthRepositoryProtocol.self) { r in AuthRepository( @@ -39,8 +59,11 @@ class ScreenAssembly: Assembly { MainScreenViewModel( analytics: r.resolve(MainScreenAnalytics.self)!, config: r.resolve(ConfigProtocol.self)!, + router: r.resolve(Router.self)!, + syncManager: r.resolve(OfflineSyncManagerProtocol.self)!, profileInteractor: r.resolve(ProfileInteractorProtocol.self)!, - appStorage: r.resolve(AppStorage.self)!, + courseInteractor: r.resolve(CourseInteractorProtocol.self)!, + appStorage: r.resolve(AppStorage.self)!, calendarManager: r.resolve(CalendarManagerProtocol.self)!, sourceScreen: sourceScreen ) @@ -240,7 +263,9 @@ class ScreenAssembly: Assembly { router: r.resolve(ProfileRouter.self)!, analytics: r.resolve(ProfileAnalytics.self)!, coreAnalytics: r.resolve(CoreAnalytics.self)!, - config: r.resolve(ConfigProtocol.self)! + config: r.resolve(ConfigProtocol.self)!, + corePersistence: r.resolve(CorePersistenceProtocol.self)!, + connectivity: r.resolve(ConnectivityProtocol.self)! ) } @@ -363,8 +388,11 @@ class ScreenAssembly: Assembly { } container.register(WebUnitViewModel.self) { r in - WebUnitViewModel(authInteractor: r.resolve(AuthInteractorProtocol.self)!, - config: r.resolve(ConfigProtocol.self)!) + WebUnitViewModel( + authInteractor: r.resolve(AuthInteractorProtocol.self)!, + config: r.resolve(ConfigProtocol.self)!, + syncManager: r.resolve(OfflineSyncManagerProtocol.self)! + ) } container.register( diff --git a/OpenEdX/Data/CorePersistence.swift b/OpenEdX/Data/CorePersistence.swift index f9282bd27..4844aafdc 100644 --- a/OpenEdX/Data/CorePersistence.swift +++ b/OpenEdX/Data/CorePersistence.swift @@ -80,34 +80,86 @@ public class CorePersistence: CorePersistenceProtocol { let userId = getUserId32() ?? 0 for block in blocks { let downloadDataId = downloadDataId(from: block.id) - await context.perform {[context] in + + await context.perform { [weak self] in + guard let self else { return } let data = try? CorePersistenceHelper.fetchCDDownloadData( predicate: CDPredicate.id(downloadDataId), - context: context, + context: self.context, userId: userId ) guard data?.first == nil else { return } - guard let video = block.encodedVideo?.video(downloadQuality: downloadQuality), - let url = video.url, - let fileExtension = URL(string: url)?.pathExtension - else { return } + var fileExtension: String? + var url: String? + var fileSize: Int32? + var fileName: String? - let fileName = "\(block.id).\(fileExtension)" + if let html = block.offlineDownload { + let fileUrl = html.fileUrl + url = fileUrl + fileSize = Int32(html.fileSize) + fileExtension = URL(string: fileUrl)?.pathExtension + if let folderName = URL(string: fileUrl)?.lastPathComponent, + let folderUrl = URL(string: folderName)?.deletingPathExtension() { + fileName = folderUrl.absoluteString + } + saveDownloadData() + } else if let encodedVideo = block.encodedVideo, + let video = encodedVideo.video(downloadQuality: downloadQuality), + let videoUrl = video.url { + url = videoUrl + if let videoFileSize = video.fileSize { + fileSize = Int32(videoFileSize) + } + fileExtension = URL(string: videoUrl)?.pathExtension + fileName = "\(block.id).\(fileExtension ?? "")" + saveDownloadData() + } else { return } + + func saveDownloadData() { + let newDownloadData = CDDownloadData(context: context) + context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + newDownloadData.id = downloadDataId + newDownloadData.blockId = block.id + newDownloadData.userId = userId + newDownloadData.courseId = block.courseId + newDownloadData.url = url + newDownloadData.fileName = fileName + newDownloadData.displayName = block.displayName + if let lastModified = block.offlineDownload?.lastModified { + newDownloadData.lastModified = lastModified + } + newDownloadData.progress = .zero + newDownloadData.resumeData = nil + newDownloadData.state = DownloadState.waiting.rawValue + newDownloadData.type = block.offlineDownload != nil + ? DownloadType.html.rawValue + : DownloadType.video.rawValue + newDownloadData.fileSize = Int32(fileSize ?? 0) + } + } + } + } + + public func addToDownloadQueue(tasks: [DownloadDataTask]) { + for task in tasks { + context.performAndWait { let newDownloadData = CDDownloadData(context: context) context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump - newDownloadData.id = downloadDataId - newDownloadData.blockId = block.id - newDownloadData.userId = userId - newDownloadData.courseId = block.courseId - newDownloadData.url = url - newDownloadData.fileName = fileName - newDownloadData.displayName = block.displayName + newDownloadData.id = task.id + newDownloadData.blockId = task.blockId + newDownloadData.userId = Int32(task.userId) + newDownloadData.courseId = task.courseId + newDownloadData.url = task.url + newDownloadData.fileName = task.fileName + newDownloadData.displayName = task.displayName + newDownloadData.lastModified = task.lastModified newDownloadData.progress = .zero newDownloadData.resumeData = nil newDownloadData.state = DownloadState.waiting.rawValue - newDownloadData.type = DownloadType.video.rawValue - newDownloadData.fileSize = Int32(video.fileSize ?? 0) + newDownloadData.type = task.type.rawValue + newDownloadData.fileSize = Int32(task.fileSize) } } } @@ -290,9 +342,117 @@ public class CorePersistence: CorePersistenceProtocol { }) .eraseToAnyPublisher() } + + // MARK: - Offline Progress + public func saveOfflineProgress(progress: OfflineProgress) { + context.performAndWait { + let progressForSaving = CDOfflineProgress(context: context) + context.mergePolicy = NSMergePolicy.mergeByPropertyObjectTrump + progressForSaving.blockID = progress.blockID + progressForSaving.progressJson = progress.progressJson + + do { + try context.save() + } catch { + debugLog("⛔️⛔️⛔️⛔️⛔️", error) + } + } + } + + public func loadProgress(for blockID: String) -> OfflineProgress? { + context.performAndWait { + let request = CDOfflineProgress.fetchRequest() + request.predicate = NSPredicate(format: "blockID = %@", blockID) + guard let progress = try? context.fetch(request).first, + let savedBlockID = progress.blockID, + let progressJson = progress.progressJson, + blockID == savedBlockID else { return nil } + + return OfflineProgress( + progressJson: progressJson + ) + } + } + + public func loadAllOfflineProgress() -> [OfflineProgress] { + context.performAndWait { + let result = try? context.fetch(CDOfflineProgress.fetchRequest()) + .map { + OfflineProgress( + progressJson: $0.progressJson ?? "" + )} + if let result, !result.isEmpty { + return result + } else { + return [] + } + } + } + + public func deleteProgress(for blockID: String) { + context.performAndWait { + let request = CDOfflineProgress.fetchRequest() + request.predicate = NSPredicate(format: "blockID = %@", blockID) + guard let progress = try? context.fetch(request).first else { return } + + do { + context.delete(progress) + try context.save() + debugLog("File erased successfully") + } catch { + debugLog("Error deleteing progress: \(error.localizedDescription)") + } + } + } + + public func deleteAllProgress() { + context.performAndWait { + let request = CDOfflineProgress.fetchRequest() + guard let allProgress = try? context.fetch(request) else { return } + + do { + for progress in allProgress { + context.delete(progress) + try context.save() + debugLog("File erased successfully") + } + } catch { + debugLog("Error deleteing progress: \(error.localizedDescription)") + } + } + } // MARK: - Private Intents + private func fetchCDDownloadData( + predicate: CDPredicate? = nil, + fetchLimit: Int? = nil + ) throws -> [CDDownloadData] { + let request = CDDownloadData.fetchRequest() + + var predicates = [NSPredicate]() + + if let predicate = predicate { + predicates.append(predicate.predicate) + } + + if let userId = getUserId32() { + let userIdNumber = NSNumber(value: userId) + let userIdPredicate = NSPredicate(format: "userId == %@", userIdNumber) + predicates.append(userIdPredicate) + } + + if !predicates.isEmpty { + request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) + } + + if let fetchLimit = fetchLimit { + request.fetchLimit = fetchLimit + } + + return try context.fetch(request) + } + private func getUserId32() -> Int32? { guard let userId else { return nil diff --git a/OpenEdX/Data/CoursePersistence.swift b/OpenEdX/Data/CoursePersistence.swift index 8ba7bc45c..eaca47bb5 100644 --- a/OpenEdX/Data/CoursePersistence.swift +++ b/OpenEdX/Data/CoursePersistence.swift @@ -86,27 +86,27 @@ public class CoursePersistence: CoursePersistenceProtocol { encodedVideo: DataLayer.CourseDetailEncodedVideoData( youTube: DataLayer.EncodedVideoData( url: $0.youTube?.url, - fileSize: Int($0.youTube?.fileSize ?? 0) + fileSize: $0.youTube?.fileSize == nil ? nil : Int($0.youTube!.fileSize) ), fallback: DataLayer.EncodedVideoData( url: $0.fallback?.url, - fileSize: Int($0.fallback?.fileSize ?? 0) + fileSize: $0.fallback?.fileSize == nil ? nil : Int($0.fallback!.fileSize) ), desktopMP4: DataLayer.EncodedVideoData( url: $0.desktopMP4?.url, - fileSize: Int($0.desktopMP4?.fileSize ?? 0) + fileSize: $0.desktopMP4?.fileSize == nil ? nil : Int($0.desktopMP4!.fileSize) ), mobileHigh: DataLayer.EncodedVideoData( url: $0.mobileHigh?.url, - fileSize: Int($0.mobileHigh?.fileSize ?? 0) + fileSize: $0.mobileHigh?.fileSize == nil ? nil : Int($0.mobileHigh!.fileSize) ), mobileLow: DataLayer.EncodedVideoData( url: $0.mobileLow?.url, - fileSize: Int($0.mobileLow?.fileSize ?? 0) + fileSize: $0.mobileLow?.fileSize == nil ? nil : Int($0.mobileLow!.fileSize) ), hls: DataLayer.EncodedVideoData( url: $0.hls?.url, - fileSize: Int($0.hls?.fileSize ?? 0) + fileSize: $0.hls?.fileSize == nil ? nil : Int($0.hls!.fileSize) ) ), topicID: "" @@ -129,6 +129,11 @@ public class CoursePersistence: CoursePersistenceProtocol { assignmentType: $0.assignmentType, numPointsEarned: $0.numPointsEarned, numPointsPossible: $0.numPointsPossible + ), + offlineDownload: DataLayer.OfflineDownload( + fileUrl: $0.fileUrl, + lastModified: $0.lastModified, + fileSize: Int($0.fileSize) ) ) } @@ -199,6 +204,15 @@ public class CoursePersistence: CoursePersistenceProtocol { if let due = block.due { courseDetail.due = due } + + if let offlineDownload = block.offlineDownload, + let fileSize = offlineDownload.fileSize, + let fileUrl = offlineDownload.fileUrl, + let lastModified = offlineDownload.lastModified { + courseDetail.fileSize = Int64(fileSize) + courseDetail.fileUrl = fileUrl + courseDetail.lastModified = lastModified + } if block.userViewData?.encodedVideo?.youTube != nil { let youTube = CDCourseBlockVideo(context: self.context) diff --git a/OpenEdX/Info.plist b/OpenEdX/Info.plist index 2b4cb0751..e9bd32e58 100644 --- a/OpenEdX/Info.plist +++ b/OpenEdX/Info.plist @@ -2,6 +2,10 @@ + BGTaskSchedulerPermittedIdentifiers + + openEdx.offlineProgressSync + Configuration $(CONFIGURATION) FirebaseAppDelegateProxyEnabled @@ -26,18 +30,20 @@ NSAllowsArbitraryLoads + NSAllowsArbitraryLoadsInWebContent + + NSCalendarsFullAccessUsageDescription + We would like to utilize your calendar list to subscribe you to your personalized calendar for this course. UIAppFonts UIBackgroundModes audio + fetch + processing 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/Managers/AnalyticsManager/AnalyticsManager.swift b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift index 404ffed0e..6949a193a 100644 --- a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift +++ b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift @@ -549,6 +549,15 @@ class AnalyticsManager: AuthorizationAnalytics, logScreenEvent(.courseOutlineVideosTabClicked, parameters: parameters) } + func courseOutlineOfflineTabClicked(courseId: String, courseName: String) { + let parameters = [ + EventParamKey.courseID: courseId, + EventParamKey.courseName: courseName, + EventParamKey.name: EventBIValue.courseOutlineOfflineTabClicked.rawValue + ] + logEvent(.courseOutlineOfflineTabClicked, parameters: parameters) + } + public func courseOutlineDatesTabClicked(courseId: String, courseName: String) { let parameters = [ EventParamKey.courseID: courseId, diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 186ff6329..d06b65e9a 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -840,7 +840,8 @@ public class Router: AuthorizationRouter, let webBrowser = WebBrowser( url: url.absoluteString, pageTitle: title, - showProgress: true + showProgress: true, + connectivity: Container.shared.resolve(ConnectivityProtocol.self)! ) let controller = UIHostingController(rootView: webBrowser) navigationController.pushViewController(controller, animated: true) diff --git a/OpenEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift index 72fa66b56..f430a4662 100644 --- a/OpenEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -162,6 +162,13 @@ struct MainScreenView: View { .onReceive(NotificationCenter.default.publisher(for: .onNewVersionAvaliable)) { _ in updateAvailable = true } + .onReceive(NotificationCenter.default.publisher(for: .showDownloadFailed)) { downloads in + if let downloads = downloads.object as? [DownloadDataTask] { + Task { + await viewModel.showDownloadFailed(downloads: downloads) + } + } + } .onChange(of: viewModel.selection) { _ in if disableAllTabs { viewModel.selection = .profile diff --git a/OpenEdX/View/MainScreenViewModel.swift b/OpenEdX/View/MainScreenViewModel.swift index 740d0fd93..70b74ca6b 100644 --- a/OpenEdX/View/MainScreenViewModel.swift +++ b/OpenEdX/View/MainScreenViewModel.swift @@ -8,6 +8,7 @@ import Foundation import Core import Profile +import Course import Swinject import Combine @@ -22,7 +23,10 @@ final class MainScreenViewModel: ObservableObject { private let analytics: MainScreenAnalytics let config: ConfigProtocol - private let profileInteractor: ProfileInteractorProtocol + let router: BaseRouter + let syncManager: OfflineSyncManagerProtocol + let profileInteractor: ProfileInteractorProtocol + let courseInteractor: CourseInteractorProtocol var sourceScreen: LogistrationSourceScreen private var appStorage: CoreStorage & ProfileStorage private let calendarManager: CalendarManagerProtocol @@ -32,14 +36,20 @@ final class MainScreenViewModel: ObservableObject { init(analytics: MainScreenAnalytics, config: ConfigProtocol, + router: BaseRouter, + syncManager: OfflineSyncManagerProtocol, profileInteractor: ProfileInteractorProtocol, + courseInteractor: CourseInteractorProtocol, appStorage: CoreStorage & ProfileStorage, calendarManager: CalendarManagerProtocol, sourceScreen: LogistrationSourceScreen = .default ) { self.analytics = analytics self.config = config + self.router = router + self.syncManager = syncManager self.profileInteractor = profileInteractor + self.courseInteractor = courseInteractor self.appStorage = appStorage self.calendarManager = calendarManager self.sourceScreen = sourceScreen @@ -71,6 +81,37 @@ final class MainScreenViewModel: ObservableObject { analytics.mainProfileTabClicked() } + @MainActor + func showDownloadFailed(downloads: [DownloadDataTask]) async { + if let sequentials = try? await courseInteractor.getSequentialsContainsBlocks( + blockIds: downloads.map { + $0.blockId + }, + courseID: downloads.first?.courseId ?? "" + ) { + router.presentView( + transitionStyle: .coverVertical, + view: DownloadErrorAlertView( + errorType: .downloadFailed, + sequentials: sequentials, + tryAgain: { [weak self] in + guard let self else { return } + NotificationCenter.default.post( + name: .tryDownloadAgain, + object: downloads + ) + self.router.dismiss(animated: true) + }, + close: { [weak self] in + guard let self else { return } + self.router.dismiss(animated: true) + } + ), + completion: {} + ) + } + } + @MainActor func prefetchDataForOffline() async { if profileInteractor.getMyProfileOffline() == nil { diff --git a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift index 29ff3c17a..3683465ab 100644 --- a/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift +++ b/Profile/Profile/Presentation/Profile/Subviews/ProfileSupportInfoView.swift @@ -119,7 +119,8 @@ struct ProfileSupportInfoView: View { WebBrowser( url: viewModel.url.absoluteString, pageTitle: viewModel.title, - showProgress: true + showProgress: true, + connectivity: self.viewModel.connectivity ) } label: { diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index e827d005e..e11926591 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -252,7 +252,9 @@ struct SettingsView_Previews: PreviewProvider { router: router, analytics: ProfileAnalyticsMock(), coreAnalytics: CoreAnalyticsMock(), - config: ConfigMock() + config: ConfigMock(), + corePersistence: CorePersistenceMock(), + connectivity: Connectivity() ) SettingsView(viewModel: vm) diff --git a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift index 1fe8ada15..98885f15c 100644 --- a/Profile/Profile/Presentation/Settings/SettingsViewModel.swift +++ b/Profile/Profile/Presentation/Settings/SettingsViewModel.swift @@ -70,6 +70,8 @@ public class SettingsViewModel: ObservableObject { let analytics: ProfileAnalytics let coreAnalytics: CoreAnalytics let config: ConfigProtocol + let corePersistence: CorePersistenceProtocol + let connectivity: ConnectivityProtocol public init( interactor: ProfileInteractorProtocol, @@ -77,7 +79,9 @@ public class SettingsViewModel: ObservableObject { router: ProfileRouter, analytics: ProfileAnalytics, coreAnalytics: CoreAnalytics, - config: ConfigProtocol + config: ConfigProtocol, + corePersistence: CorePersistenceProtocol, + connectivity: ConnectivityProtocol ) { self.interactor = interactor self.downloadManager = downloadManager @@ -85,6 +89,8 @@ public class SettingsViewModel: ObservableObject { self.analytics = analytics self.coreAnalytics = coreAnalytics self.config = config + self.corePersistence = corePersistence + self.connectivity = connectivity let userSettings = interactor.getSettings() self.userSettings = userSettings @@ -137,6 +143,7 @@ public class SettingsViewModel: ObservableObject { func logOut() async { try? await interactor.logOut() try? await downloadManager.cancelAllDownloading() + corePersistence.deleteAllProgress() router.showStartupScreen() analytics.userLogout(force: false) NotificationCenter.default.post( diff --git a/Profile/Profile/Presentation/Settings/VideoQualityView.swift b/Profile/Profile/Presentation/Settings/VideoQualityView.swift index a52565c19..3ad89ad78 100644 --- a/Profile/Profile/Presentation/Settings/VideoQualityView.swift +++ b/Profile/Profile/Presentation/Settings/VideoQualityView.swift @@ -132,7 +132,9 @@ struct VideoQualityView_Previews: PreviewProvider { router: router, analytics: ProfileAnalyticsMock(), coreAnalytics: CoreAnalyticsMock(), - config: ConfigMock() + config: ConfigMock(), + corePersistence: CorePersistenceMock(), + connectivity: Connectivity() ) VideoQualityView(viewModel: vm) diff --git a/Profile/Profile/Presentation/Settings/VideoSettingsView.swift b/Profile/Profile/Presentation/Settings/VideoSettingsView.swift index 98e14ebb2..f54446cec 100644 --- a/Profile/Profile/Presentation/Settings/VideoSettingsView.swift +++ b/Profile/Profile/Presentation/Settings/VideoSettingsView.swift @@ -136,7 +136,9 @@ struct VideoSettingsView_Previews: PreviewProvider { router: router, analytics: ProfileAnalyticsMock(), coreAnalytics: CoreAnalyticsMock(), - config: ConfigMock() + config: ConfigMock(), + corePersistence: CorePersistenceMock(), + connectivity: Connectivity() ) VideoSettingsView(viewModel: vm) diff --git a/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift b/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift index b9c77c6eb..de7d52a4c 100644 --- a/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/Settings/SettingsViewModelTests.swift @@ -37,7 +37,9 @@ final class SettingsViewModelTests: XCTestCase { router: router, analytics: analytics, coreAnalytics: coreAnalytics, - config: ConfigMock() + config: ConfigMock(), + corePersistence: CorePersistenceMock(), + connectivity: Connectivity() ) await viewModel.logOut() @@ -69,7 +71,9 @@ final class SettingsViewModelTests: XCTestCase { router: router, analytics: analytics, coreAnalytics: coreAnalytics, - config: ConfigMock() + config: ConfigMock(), + corePersistence: CorePersistenceMock(), + connectivity: Connectivity() ) viewModel.trackProfileVideoSettingsClicked() @@ -100,7 +104,9 @@ final class SettingsViewModelTests: XCTestCase { router: router, analytics: analytics, coreAnalytics: coreAnalytics, - config: ConfigMock() + config: ConfigMock(), + corePersistence: CorePersistenceMock(), + connectivity: Connectivity() ) viewModel.trackEmailSupportClicked() @@ -131,7 +137,9 @@ final class SettingsViewModelTests: XCTestCase { router: router, analytics: analytics, coreAnalytics: coreAnalytics, - config: ConfigMock() + config: ConfigMock(), + corePersistence: CorePersistenceMock(), + connectivity: Connectivity() ) viewModel.trackCookiePolicyClicked() @@ -162,7 +170,9 @@ final class SettingsViewModelTests: XCTestCase { router: router, analytics: analytics, coreAnalytics: coreAnalytics, - config: ConfigMock() + config: ConfigMock(), + corePersistence: CorePersistenceMock(), + connectivity: Connectivity() ) viewModel.trackPrivacyPolicyClicked() @@ -193,7 +203,9 @@ final class SettingsViewModelTests: XCTestCase { router: router, analytics: analytics, coreAnalytics: coreAnalytics, - config: ConfigMock() + config: ConfigMock(), + corePersistence: CorePersistenceMock(), + connectivity: Connectivity() ) viewModel.trackProfileEditClicked() diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 843268a30..ec514f18d 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -1466,6 +1466,611 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { } } +// MARK: - CorePersistenceProtocol + +open class CorePersistenceProtocolMock: CorePersistenceProtocol, 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 set(userId: Int) { + addInvocation(.m_set__userId_userId(Parameter.value(`userId`))) + let perform = methodPerformValue(.m_set__userId_userId(Parameter.value(`userId`))) as? (Int) -> Void + perform?(`userId`) + } + + open func getUserID() -> Int? { + addInvocation(.m_getUserID) + let perform = methodPerformValue(.m_getUserID) as? () -> Void + perform?() + var __value: Int? = nil + do { + __value = try methodReturnValue(.m_getUserID).casted() + } catch { + // do nothing + } + return __value + } + + open func publisher() -> AnyPublisher { + addInvocation(.m_publisher) + let perform = methodPerformValue(.m_publisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_publisher).casted() + } catch { + onFatalFailure("Stub return value not specified for publisher(). Use given") + Failure("Stub return value not specified for publisher(). Use given") + } + return __value + } + + open func addToDownloadQueue(tasks: [DownloadDataTask]) { + addInvocation(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) as? ([DownloadDataTask]) -> Void + perform?(`tasks`) + } + + open func saveOfflineProgress(progress: OfflineProgress) { + addInvocation(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) + let perform = methodPerformValue(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) as? (OfflineProgress) -> Void + perform?(`progress`) + } + + open func loadProgress(for blockID: String) -> OfflineProgress? { + addInvocation(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + var __value: OfflineProgress? = nil + do { + __value = try methodReturnValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))).casted() + } catch { + // do nothing + } + return __value + } + + open func loadAllOfflineProgress() -> [OfflineProgress] { + addInvocation(.m_loadAllOfflineProgress) + let perform = methodPerformValue(.m_loadAllOfflineProgress) as? () -> Void + perform?() + var __value: [OfflineProgress] + do { + __value = try methodReturnValue(.m_loadAllOfflineProgress).casted() + } catch { + onFatalFailure("Stub return value not specified for loadAllOfflineProgress(). Use given") + Failure("Stub return value not specified for loadAllOfflineProgress(). Use given") + } + return __value + } + + open func deleteProgress(for blockID: String) { + addInvocation(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + } + + open func deleteAllProgress() { + addInvocation(.m_deleteAllProgress) + let perform = methodPerformValue(.m_deleteAllProgress) as? () -> Void + perform?() + } + + open func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) { + addInvocation(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) as? ([CourseBlock], DownloadQuality) -> Void + perform?(`blocks`, `downloadQuality`) + } + + open func nextBlockForDownloading() -> DownloadDataTask? { + addInvocation(.m_nextBlockForDownloading) + let perform = methodPerformValue(.m_nextBlockForDownloading) as? () -> Void + perform?() + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_nextBlockForDownloading).casted() + } catch { + // do nothing + } + return __value + } + + open func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) { + addInvocation(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) + let perform = methodPerformValue(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) as? (String, DownloadState, Data?) -> Void + perform?(`id`, `state`, `resumeData`) + } + + open func deleteDownloadDataTask(id: String) throws { + addInvocation(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) + let perform = methodPerformValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) as? (String) -> Void + perform?(`id`) + do { + _ = try methodReturnValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func saveDownloadDataTask(_ task: DownloadDataTask) { + addInvocation(.m_saveDownloadDataTask__task(Parameter.value(`task`))) + let perform = methodPerformValue(.m_saveDownloadDataTask__task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void + perform?(`task`) + } + + open func downloadDataTask(for blockId: String) -> DownloadDataTask? { + addInvocation(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void + perform?(`blockId`) + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))).casted() + } catch { + // do nothing + } + return __value + } + + open func getDownloadDataTasks() -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasks) + let perform = methodPerformValue(.m_getDownloadDataTasks) as? () -> Void + perform?() + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasks).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasks(). Use given") + Failure("Stub return value not specified for getDownloadDataTasks(). Use given") + } + return __value + } + + open func getDownloadDataTasksForCourse(_ courseId: String) -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + } + return __value + } + + + fileprivate enum MethodType { + case m_set__userId_userId(Parameter) + case m_getUserID + case m_publisher + case m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>) + case m_saveOfflineProgress__progress_progress(Parameter) + case m_loadProgress__for_blockID(Parameter) + case m_loadAllOfflineProgress + case m_deleteProgress__for_blockID(Parameter) + case m_deleteAllProgress + case m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>, Parameter) + case m_nextBlockForDownloading + case m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter, Parameter, Parameter) + case m_deleteDownloadDataTask__id_id(Parameter) + case m_saveDownloadDataTask__task(Parameter) + case m_downloadDataTask__for_blockId(Parameter) + case m_getDownloadDataTasks + case m_getDownloadDataTasksForCourse__courseId(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_set__userId_userId(let lhsUserid), .m_set__userId_userId(let rhsUserid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUserid, rhs: rhsUserid, with: matcher), lhsUserid, rhsUserid, "userId")) + return Matcher.ComparisonResult(results) + + case (.m_getUserID, .m_getUserID): return .match + + case (.m_publisher, .m_publisher): return .match + + case (.m_addToDownloadQueue__tasks_tasks(let lhsTasks), .m_addToDownloadQueue__tasks_tasks(let rhsTasks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTasks, rhs: rhsTasks, with: matcher), lhsTasks, rhsTasks, "tasks")) + return Matcher.ComparisonResult(results) + + case (.m_saveOfflineProgress__progress_progress(let lhsProgress), .m_saveOfflineProgress__progress_progress(let rhsProgress)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsProgress, rhs: rhsProgress, with: matcher), lhsProgress, rhsProgress, "progress")) + return Matcher.ComparisonResult(results) + + case (.m_loadProgress__for_blockID(let lhsBlockid), .m_loadProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_loadAllOfflineProgress, .m_loadAllOfflineProgress): return .match + + case (.m_deleteProgress__for_blockID(let lhsBlockid), .m_deleteProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_deleteAllProgress, .m_deleteAllProgress): return .match + + case (.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let lhsBlocks, let lhsDownloadquality), .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let rhsBlocks, let rhsDownloadquality)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDownloadquality, rhs: rhsDownloadquality, with: matcher), lhsDownloadquality, rhsDownloadquality, "downloadQuality")) + return Matcher.ComparisonResult(results) + + case (.m_nextBlockForDownloading, .m_nextBlockForDownloading): return .match + + case (.m_updateDownloadState__id_idstate_stateresumeData_resumeData(let lhsId, let lhsState, let lhsResumedata), .m_updateDownloadState__id_idstate_stateresumeData_resumeData(let rhsId, let rhsState, let rhsResumedata)): + 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: lhsState, rhs: rhsState, with: matcher), lhsState, rhsState, "state")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsResumedata, rhs: rhsResumedata, with: matcher), lhsResumedata, rhsResumedata, "resumeData")) + return Matcher.ComparisonResult(results) + + case (.m_deleteDownloadDataTask__id_id(let lhsId), .m_deleteDownloadDataTask__id_id(let rhsId)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "id")) + return Matcher.ComparisonResult(results) + + case (.m_saveDownloadDataTask__task(let lhsTask), .m_saveDownloadDataTask__task(let rhsTask)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "_ task")) + return Matcher.ComparisonResult(results) + + case (.m_downloadDataTask__for_blockId(let lhsBlockid), .m_downloadDataTask__for_blockId(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + return Matcher.ComparisonResult(results) + + case (.m_getDownloadDataTasks, .m_getDownloadDataTasks): return .match + + case (.m_getDownloadDataTasksForCourse__courseId(let lhsCourseid), .m_getDownloadDataTasksForCourse__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 + } + } + + func intValue() -> Int { + switch self { + case let .m_set__userId_userId(p0): return p0.intValue + case .m_getUserID: return 0 + case .m_publisher: return 0 + case let .m_addToDownloadQueue__tasks_tasks(p0): return p0.intValue + case let .m_saveOfflineProgress__progress_progress(p0): return p0.intValue + case let .m_loadProgress__for_blockID(p0): return p0.intValue + case .m_loadAllOfflineProgress: return 0 + case let .m_deleteProgress__for_blockID(p0): return p0.intValue + case .m_deleteAllProgress: return 0 + case let .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(p0, p1): return p0.intValue + p1.intValue + case .m_nextBlockForDownloading: return 0 + case let .m_updateDownloadState__id_idstate_stateresumeData_resumeData(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_deleteDownloadDataTask__id_id(p0): return p0.intValue + case let .m_saveDownloadDataTask__task(p0): return p0.intValue + case let .m_downloadDataTask__for_blockId(p0): return p0.intValue + case .m_getDownloadDataTasks: return 0 + case let .m_getDownloadDataTasksForCourse__courseId(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_set__userId_userId: return ".set(userId:)" + case .m_getUserID: return ".getUserID()" + case .m_publisher: return ".publisher()" + case .m_addToDownloadQueue__tasks_tasks: return ".addToDownloadQueue(tasks:)" + case .m_saveOfflineProgress__progress_progress: return ".saveOfflineProgress(progress:)" + case .m_loadProgress__for_blockID: return ".loadProgress(for:)" + case .m_loadAllOfflineProgress: return ".loadAllOfflineProgress()" + case .m_deleteProgress__for_blockID: return ".deleteProgress(for:)" + case .m_deleteAllProgress: return ".deleteAllProgress()" + case .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality: return ".addToDownloadQueue(blocks:downloadQuality:)" + case .m_nextBlockForDownloading: return ".nextBlockForDownloading()" + case .m_updateDownloadState__id_idstate_stateresumeData_resumeData: return ".updateDownloadState(id:state:resumeData:)" + case .m_deleteDownloadDataTask__id_id: return ".deleteDownloadDataTask(id:)" + case .m_saveDownloadDataTask__task: return ".saveDownloadDataTask(_:)" + case .m_downloadDataTask__for_blockId: return ".downloadDataTask(for:)" + case .m_getDownloadDataTasks: return ".getDownloadDataTasks()" + case .m_getDownloadDataTasksForCourse__courseId: return ".getDownloadDataTasksForCourse(_:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func getUserID(willReturn: Int?...) -> MethodStub { + return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func publisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadProgress(for blockID: Parameter, willReturn: OfflineProgress?...) -> MethodStub { + return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadAllOfflineProgress(willReturn: [OfflineProgress]...) -> MethodStub { + return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func nextBlockForDownloading(willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func downloadDataTask(for blockId: Parameter, willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasks(willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getUserID(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Int?] = [] + let given: Given = { return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Int?).self) + willProduce(stubber) + return given + } + public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func loadProgress(for blockID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [OfflineProgress?] = [] + let given: Given = { return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (OfflineProgress?).self) + willProduce(stubber) + return given + } + public static func loadAllOfflineProgress(willProduce: (Stubber<[OfflineProgress]>) -> Void) -> MethodStub { + let willReturn: [[OfflineProgress]] = [] + let given: Given = { return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([OfflineProgress]).self) + willProduce(stubber) + return given + } + public static func nextBlockForDownloading(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func downloadDataTask(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func deleteDownloadDataTask(id: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func deleteDownloadDataTask(id: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func set(userId: Parameter) -> Verify { return Verify(method: .m_set__userId_userId(`userId`))} + public static func getUserID() -> Verify { return Verify(method: .m_getUserID)} + public static func publisher() -> Verify { return Verify(method: .m_publisher)} + public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>) -> Verify { return Verify(method: .m_addToDownloadQueue__tasks_tasks(`tasks`))} + public static func saveOfflineProgress(progress: Parameter) -> Verify { return Verify(method: .m_saveOfflineProgress__progress_progress(`progress`))} + public static func loadProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_loadProgress__for_blockID(`blockID`))} + public static func loadAllOfflineProgress() -> Verify { return Verify(method: .m_loadAllOfflineProgress)} + public static func deleteProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_deleteProgress__for_blockID(`blockID`))} + public static func deleteAllProgress() -> Verify { return Verify(method: .m_deleteAllProgress)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`))} + public static func nextBlockForDownloading() -> Verify { return Verify(method: .m_nextBlockForDownloading)} + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter) -> Verify { return Verify(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`))} + public static func deleteDownloadDataTask(id: Parameter) -> Verify { return Verify(method: .m_deleteDownloadDataTask__id_id(`id`))} + public static func saveDownloadDataTask(_ task: Parameter) -> Verify { return Verify(method: .m_saveDownloadDataTask__task(`task`))} + public static func downloadDataTask(for blockId: Parameter) -> Verify { return Verify(method: .m_downloadDataTask__for_blockId(`blockId`))} + public static func getDownloadDataTasks() -> Verify { return Verify(method: .m_getDownloadDataTasks)} + public static func getDownloadDataTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func set(userId: Parameter, perform: @escaping (Int) -> Void) -> Perform { + return Perform(method: .m_set__userId_userId(`userId`), performs: perform) + } + public static func getUserID(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getUserID, performs: perform) + } + public static func publisher(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_publisher, performs: perform) + } + public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>, perform: @escaping ([DownloadDataTask]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__tasks_tasks(`tasks`), performs: perform) + } + public static func saveOfflineProgress(progress: Parameter, perform: @escaping (OfflineProgress) -> Void) -> Perform { + return Perform(method: .m_saveOfflineProgress__progress_progress(`progress`), performs: perform) + } + public static func loadProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_loadProgress__for_blockID(`blockID`), performs: perform) + } + public static func loadAllOfflineProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_loadAllOfflineProgress, performs: perform) + } + public static func deleteProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteProgress__for_blockID(`blockID`), performs: perform) + } + public static func deleteAllProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllProgress, performs: perform) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter, perform: @escaping ([CourseBlock], DownloadQuality) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`), performs: perform) + } + public static func nextBlockForDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_nextBlockForDownloading, performs: perform) + } + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter, perform: @escaping (String, DownloadState, Data?) -> Void) -> Perform { + return Perform(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`), performs: perform) + } + public static func deleteDownloadDataTask(id: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteDownloadDataTask__id_id(`id`), performs: perform) + } + public static func saveDownloadDataTask(_ task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { + return Perform(method: .m_saveDownloadDataTask__task(`task`), performs: perform) + } + public static func downloadDataTask(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_downloadDataTask__for_blockId(`blockId`), performs: perform) + } + public static func getDownloadDataTasks(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasks, performs: perform) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), 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 { @@ -1661,6 +2266,20 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] { + addInvocation(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) + let perform = methodPerformValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) as? ([CourseSequential]) -> Void + perform?(`sequentials`) + var __value: [CourseSequential] + do { + __value = try methodReturnValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))).casted() + } catch { + onFatalFailure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + Failure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + } + return __value + } + open func resumeDownloading() throws { addInvocation(.m_resumeDownloading) let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void @@ -1708,6 +2327,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_deleteFile__blocks_blocks(Parameter<[CourseBlock]>) case m_deleteAllFiles case m_fileUrl__for_blockId(Parameter) + case m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) case m_removeAppSupportDirectoryUnusedContent @@ -1761,6 +2381,11 @@ 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_updateUnzippedFileSize__for_sequentials(let lhsSequentials), .m_updateUnzippedFileSize__for_sequentials(let rhsSequentials)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSequentials, rhs: rhsSequentials, with: matcher), lhsSequentials, rhsSequentials, "for sequentials")) + return Matcher.ComparisonResult(results) + case (.m_resumeDownloading, .m_resumeDownloading): return .match case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): @@ -1788,6 +2413,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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_updateUnzippedFileSize__for_sequentials(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue case .m_removeAppSupportDirectoryUnusedContent: return 0 @@ -1808,6 +2434,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_deleteFile__blocks_blocks: return ".deleteFile(blocks:)" case .m_deleteAllFiles: return ".deleteAllFiles()" case .m_fileUrl__for_blockId: return ".fileUrl(for:)" + case .m_updateUnzippedFileSize__for_sequentials: return ".updateUnzippedFileSize(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" @@ -1843,6 +2470,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willReturn: [CourseSequential]...) -> MethodStub { + return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willReturn: Bool...) -> MethodStub { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -1881,6 +2511,13 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { willProduce(stubber) return given } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willProduce: (Stubber<[CourseSequential]>) -> Void) -> MethodStub { + let willReturn: [[CourseSequential]] = [] + let given: Given = { return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseSequential]).self) + willProduce(stubber) + return given + } public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willProduce: (Stubber) -> Void) -> MethodStub { let willReturn: [Bool] = [] let given: Given = { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() @@ -1965,6 +2602,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { 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 updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>) -> Verify { return Verify(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`))} 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 func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} @@ -2011,6 +2649,9 @@ 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 updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, perform: @escaping ([CourseSequential]) -> Void) -> Perform { + return Perform(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), performs: perform) + } public static func resumeDownloading(perform: @escaping () -> Void) -> Perform { return Perform(method: .m_resumeDownloading, performs: perform) } @@ -2095,6 +2736,205 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } +// MARK: - OfflineSyncInteractorProtocol + +open class OfflineSyncInteractorProtocolMock: OfflineSyncInteractorProtocol, 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 submitOfflineProgress(courseID: String, blockID: String, data: String) throws -> Bool { + addInvocation(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) + let perform = methodPerformValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) as? (String, String, String) -> Void + perform?(`courseID`, `blockID`, `data`) + var __value: Bool + do { + __value = try methodReturnValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + Failure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + } catch { + throw error + } + return __value + } + + + fileprivate enum MethodType { + case m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter, Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let lhsCourseid, let lhsBlockid, let lhsData), .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let rhsCourseid, let rhsBlockid, let rhsData)): + 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: lhsData, rhs: rhsData, with: matcher), lhsData, rhsData, "data")) + return Matcher.ComparisonResult(results) + } + } + + func intValue() -> Int { + switch self { + case let .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + } + } + func assertionName() -> String { + switch self { + case .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data: return ".submitOfflineProgress(courseID:blockID:data:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willReturn: Bool...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Bool).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter) -> Verify { return Verify(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { + return Perform(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), 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: - ProfileAnalytics open class ProfileAnalyticsMock: ProfileAnalytics, Mock { diff --git a/Theme/Theme/Assets.xcassets/Colors/Background.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/Background.colorset/Contents.json index 8fef18d07..df1a5f141 100644 --- a/Theme/Theme/Assets.xcassets/Colors/Background.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/Background.colorset/Contents.json @@ -23,9 +23,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.184", - "green" : "0.129", - "red" : "0.098" + "blue" : "0x2E", + "green" : "0x20", + "red" : "0x18" } }, "idiom" : "universal" diff --git a/Theme/Theme/Assets.xcassets/Colors/CourseDates/NextWeekTimelineColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/CourseDates/NextWeekTimelineColor.colorset/Contents.json index 5cd29db93..d7638b312 100644 --- a/Theme/Theme/Assets.xcassets/Colors/CourseDates/NextWeekTimelineColor.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/CourseDates/NextWeekTimelineColor.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.733", - "green" : "0.647", - "red" : "0.592" + "blue" : "0xBA", + "green" : "0xA4", + "red" : "0x96" } }, "idiom" : "universal" diff --git a/Theme/Theme/Assets.xcassets/Colors/CourseDates/UpcomingTimelineColor.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/CourseDates/UpcomingTimelineColor.colorset/Contents.json index 2af3cc3c3..34275d32c 100644 --- a/Theme/Theme/Assets.xcassets/Colors/CourseDates/UpcomingTimelineColor.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/CourseDates/UpcomingTimelineColor.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.878", - "green" : "0.831", - "red" : "0.800" + "blue" : "0xDF", + "green" : "0xD3", + "red" : "0xCC" } }, "idiom" : "universal" diff --git a/Theme/Theme/Assets.xcassets/Colors/StyledButton/disabledButton.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/StyledButton/disabledButton.colorset/Contents.json new file mode 100644 index 000000000..1d2b5d427 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/StyledButton/disabledButton.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xE0", + "green" : "0xD4", + "red" : "0xCC" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xAE", + "green" : "0x9B", + "red" : "0x8E" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/Colors/StyledButton/disabledButtonText.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/StyledButton/disabledButtonText.colorset/Contents.json new file mode 100644 index 000000000..3c2cd067c --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/StyledButton/disabledButtonText.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x64", + "green" : "0x49", + "red" : "0x3D" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2F", + "green" : "0x21", + "red" : "0x19" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/Assets.xcassets/Colors/TextInput/TextInputUnfocusedStroke.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/TextInput/TextInputUnfocusedStroke.colorset/Contents.json index 432fab345..b0ae672b6 100644 --- a/Theme/Theme/Assets.xcassets/Colors/TextInput/TextInputUnfocusedStroke.colorset/Contents.json +++ b/Theme/Theme/Assets.xcassets/Colors/TextInput/TextInputUnfocusedStroke.colorset/Contents.json @@ -5,9 +5,9 @@ "color-space" : "srgb", "components" : { "alpha" : "1.000", - "blue" : "0.878", - "green" : "0.831", - "red" : "0.800" + "blue" : "0xDF", + "green" : "0xD3", + "red" : "0xCC" } }, "idiom" : "universal" diff --git a/Theme/Theme/Assets.xcassets/Colors/shade.colorset/Contents.json b/Theme/Theme/Assets.xcassets/Colors/shade.colorset/Contents.json new file mode 100644 index 000000000..0e22f05c2 --- /dev/null +++ b/Theme/Theme/Assets.xcassets/Colors/shade.colorset/Contents.json @@ -0,0 +1,38 @@ +{ + "colors" : [ + { + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0xFB", + "green" : "0xFA", + "red" : "0xF9" + } + }, + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "color" : { + "color-space" : "srgb", + "components" : { + "alpha" : "1.000", + "blue" : "0x2E", + "green" : "0x20", + "red" : "0x18" + } + }, + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Theme/Theme/SwiftGen/ThemeAssets.swift b/Theme/Theme/SwiftGen/ThemeAssets.swift index 5a9ed5fe8..c7c49e9b2 100644 --- a/Theme/Theme/SwiftGen/ThemeAssets.swift +++ b/Theme/Theme/SwiftGen/ThemeAssets.swift @@ -67,6 +67,8 @@ public enum ThemeAssets { public static let snackbarTextColor = ColorAsset(name: "SnackbarTextColor") public static let snackbarWarningColor = ColorAsset(name: "SnackbarWarningColor") public static let styledButtonText = ColorAsset(name: "StyledButtonText") + public static let disabledButton = ColorAsset(name: "disabledButton") + public static let disabledButtonText = ColorAsset(name: "disabledButtonText") public static let success = ColorAsset(name: "Success") public static let tabbarColor = ColorAsset(name: "TabbarColor") public static let textPrimary = ColorAsset(name: "TextPrimary") @@ -80,6 +82,7 @@ public enum ThemeAssets { public static let textInputUnfocusedStroke = ColorAsset(name: "TextInputUnfocusedStroke") public static let toggleSwitchColor = ColorAsset(name: "ToggleSwitchColor") public static let navigationBarTintColor = ColorAsset(name: "navigationBarTintColor") + public static let shade = ColorAsset(name: "shade") public static let warning = ColorAsset(name: "warning") public static let warningText = ColorAsset(name: "warningText") public static let white = ColorAsset(name: "white") diff --git a/Theme/Theme/Theme.swift b/Theme/Theme/Theme.swift index 50fa75878..ddff37c66 100644 --- a/Theme/Theme/Theme.swift +++ b/Theme/Theme/Theme.swift @@ -37,6 +37,8 @@ public struct Theme { 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 disabledButton = ThemeAssets.disabledButton.swiftUIColor + public private(set) static var disabledButtonText = ThemeAssets.disabledButtonText.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 @@ -72,6 +74,7 @@ public struct Theme { public private(set) static var primaryHeaderColor = ThemeAssets.primaryHeaderColor.swiftUIColor public private(set) static var secondaryHeaderColor = ThemeAssets.secondaryHeaderColor.swiftUIColor public private(set) static var courseCardShadow = ThemeAssets.courseCardShadow.swiftUIColor + public private(set) static var shade = ThemeAssets.shade.swiftUIColor public private(set) static var courseCardBackground = ThemeAssets.courseCardBackground.swiftUIColor public static func update( From a183f183ef6923fd69a5b39fa7c3d5a89312c553 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Mon, 30 Sep 2024 10:19:26 +0200 Subject: [PATCH 35/55] fix: BugFix for PrimaryEnrollment data (#516) * Merge pull request #22 from edx/fix/PrimaryEnrollment_data fix: quick fix for primary enrolment data * chore: deleted unused parameter for init --------- Co-authored-by: Vadim Kuznetsov Co-authored-by: Anton Yarmolenko <37253+rnr@users.noreply.github.com> --- Core/Core/Data/Model/Data_PrimaryEnrollment.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Core/Core/Data/Model/Data_PrimaryEnrollment.swift b/Core/Core/Data/Model/Data_PrimaryEnrollment.swift index 60764c78a..cbf70fc81 100644 --- a/Core/Core/Data/Model/Data_PrimaryEnrollment.swift +++ b/Core/Core/Data/Model/Data_PrimaryEnrollment.swift @@ -28,7 +28,7 @@ public extension DataLayer { // MARK: - Primary struct ActiveEnrollment: Codable { - public let auditAccessExpires: Date? + public let auditAccessExpires: String? public let created: String? public let mode: String? public let isActive: Bool? @@ -53,7 +53,7 @@ public extension DataLayer { } public init( - auditAccessExpires: Date?, + auditAccessExpires: String?, created: String?, mode: String?, isActive: Bool?, From 61b3e37adad7965dbb0440c3930e262c92dbe9f3 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Mon, 30 Sep 2024 10:19:47 +0200 Subject: [PATCH 36/55] fix: picker crash when no elements (#63) (#517) Co-authored-by: Vadim Kuznetsov --- Core/Core/View/Base/PickerMenu.swift | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/Core/Core/View/Base/PickerMenu.swift b/Core/Core/View/Base/PickerMenu.swift index 0de023381..cd3d6c49e 100644 --- a/Core/Core/View/Base/PickerMenu.swift +++ b/Core/Core/View/Base/PickerMenu.swift @@ -33,6 +33,7 @@ public struct PickerMenu: View { private let router: BaseRouter private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } private var selected: ((PickerItem) -> Void) = { _ in } + private let emptyKey: String = "--empty--" public init( items: [PickerItem], @@ -50,18 +51,19 @@ public struct PickerMenu: View { private var filteredItems: [PickerItem] { if search.isEmpty { - return items + return items.isEmpty ? [PickerItem(key: emptyKey, value: "")] : items } else { - return items.filter { $0.value.localizedCaseInsensitiveContains(search) } + let filteredItems = items.filter { $0.value.localizedCaseInsensitiveContains(search) } + return filteredItems.isEmpty ? [PickerItem(key: emptyKey, value: "")] : filteredItems } } private var isSingleSelection: Bool { - return filteredItems.count == 1 + return filteredItems.count == 1 && filteredItems.first?.key != emptyKey } private var isItemSelected: Bool { - return filteredItems.contains(selectedItem) + return filteredItems.contains(selectedItem) && selectedItem.key != emptyKey } private var acceptButtonDisabled: Bool { From 8dbdcb209edce8d3a042c390f050f384449642d0 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Mon, 30 Sep 2024 10:20:23 +0200 Subject: [PATCH 37/55] fix: fix iPad crash of alert controller (#74) (#521) Co-authored-by: Saeed Bashir --- Core/Core/View/Base/Webview/WebView.swift | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/Core/Core/View/Base/Webview/WebView.swift b/Core/Core/View/Base/Webview/WebView.swift index 78d974b29..f23097445 100644 --- a/Core/Core/View/Base/Webview/WebView.swift +++ b/Core/Core/View/Base/Webview/WebView.swift @@ -136,6 +136,17 @@ public struct WebView: UIViewRepresentable { handler: { _ in completionHandler(false) })) + + if let presenter = alertController.popoverPresentationController { + let view = UIApplication.topViewController()?.view + presenter.sourceView = view + presenter.sourceRect = CGRect( + x: view?.bounds.midX ?? 0, + y: view?.bounds.midY ?? 0, + width: 0, + height: 0 + ) + } UIApplication.topViewController()?.present(alertController, animated: true, completion: nil) } From d5a55fac59d771eb6d07576eae5a995cf2b3aad3 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Mon, 30 Sep 2024 10:21:03 +0200 Subject: [PATCH 38/55] fix: video crashes (#70) (#520) Co-authored-by: Saeed Bashir --- Course/Course/Data/CourseRepository.swift | 2 +- Course/Course/Domain/CourseInteractor.swift | 10 ++++++++-- .../Presentation/Video/PlayerTrackerProtocol.swift | 8 +++++++- .../Presentation/Video/VideoPlayerViewModel.swift | 2 +- 4 files changed, 17 insertions(+), 5 deletions(-) diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index c068f4722..5da361928 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -256,7 +256,7 @@ public class CourseRepository: CourseRepositoryProtocol { } private func parseVideo(encodedVideo: DataLayer.EncodedVideoData?) -> CourseBlockVideo? { - guard let encodedVideo else { + guard let encodedVideo, encodedVideo.url?.isEmpty == false else { return nil } return .init( diff --git a/Course/Course/Domain/CourseInteractor.swift b/Course/Course/Domain/CourseInteractor.swift index e4498d024..42f6074f4 100644 --- a/Course/Course/Domain/CourseInteractor.swift +++ b/Course/Course/Domain/CourseInteractor.swift @@ -216,9 +216,15 @@ public class CourseInteractor: CourseInteractorProtocol { let endTime = startAndEndTimes.last ?? "00:00:00,000" let text = lines[2.. endTimeInverval { + endTimeInverval = startTimeInterval + } + let subtitle = Subtitle(id: id, - fromTo: DateInterval(start: Date(subtitleTime: startTime), - end: Date(subtitleTime: endTime)), + fromTo: DateInterval(start: startTimeInterval, + end: endTimeInverval), text: text.decodedHTMLEntities()) subtitles.append(subtitle) } diff --git a/Course/Course/Presentation/Video/PlayerTrackerProtocol.swift b/Course/Course/Presentation/Video/PlayerTrackerProtocol.swift index 4487bab29..775f8e0d6 100644 --- a/Course/Course/Presentation/Video/PlayerTrackerProtocol.swift +++ b/Course/Course/Presentation/Video/PlayerTrackerProtocol.swift @@ -111,7 +111,13 @@ public class PlayerTracker: PlayerTrackerProtocol { item = AVPlayerItem(url: url) } self.player = AVPlayer(playerItem: item) - timePublisher = CurrentValueSubject(player?.currentTime().seconds ?? 0) + + var playerTime = player?.currentTime().seconds ?? 0.0 + if playerTime.isNaN == true { + playerTime = 0.0 + } + + timePublisher = CurrentValueSubject(playerTime) ratePublisher = CurrentValueSubject(player?.rate ?? 0) finishPublisher = PassthroughSubject() readyPublisher = PassthroughSubject() diff --git a/Course/Course/Presentation/Video/VideoPlayerViewModel.swift b/Course/Course/Presentation/Video/VideoPlayerViewModel.swift index 8e95a31f0..5014596da 100644 --- a/Course/Course/Presentation/Video/VideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/VideoPlayerViewModel.swift @@ -103,7 +103,7 @@ public class VideoPlayerViewModel: ObservableObject { subtitles = result } catch { - print(">>>>> ⛔️⛔️⛔️⛔️⛔️⛔️⛔️⛔️", error) + debugLog(">>>>> ⛔️⛔️⛔️⛔️⛔️⛔️⛔️⛔️", error) } } From e85e9704fadfbb25d5ceea7d4797949e8cd0001d Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Mon, 30 Sep 2024 10:22:32 +0200 Subject: [PATCH 39/55] fix: fix coredata crash on primary course (#64) (#518) Co-authored-by: Saeed Bashir --- OpenEdX/Data/DashboardPersistence.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/OpenEdX/Data/DashboardPersistence.swift b/OpenEdX/Data/DashboardPersistence.swift index ab0f14e52..0a55aeaf7 100644 --- a/OpenEdX/Data/DashboardPersistence.swift +++ b/OpenEdX/Data/DashboardPersistence.swift @@ -231,7 +231,7 @@ public class DashboardPersistence: DashboardPersistenceProtocol { // swiftlint:enable function_body_length func clearOldEnrollmentsData() { - context.perform {[context] in + context.performAndWait {[context] in let fetchRequest1: NSFetchRequest = CDDashboardCourse.fetchRequest() let batchDeleteRequest1 = NSBatchDeleteRequest(fetchRequest: fetchRequest1) From 2433b22ee0c76ec704837b6f29dcfc71d063677b Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Mon, 30 Sep 2024 15:41:28 +0200 Subject: [PATCH 40/55] fix: core data crash with inverse attribute (#519) * fix: core data crash with inverse attribute (#69) * chore: set deletetionRule as Cascade --------- Co-authored-by: Vadim Kuznetsov Co-authored-by: Anton Yarmolenko <37253+rnr@users.noreply.github.com> --- .../DashboardCoreModel.xcdatamodel/contents | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents b/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents index 141c99dbd..65dbea3a0 100644 --- a/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents +++ b/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents @@ -1,12 +1,14 @@ - - + + + + @@ -54,12 +56,12 @@ - - + + - + \ No newline at end of file From 391caae025f98e4f5fb862adf1aaa49537ae4dbc Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Mon, 30 Sep 2024 15:42:14 +0200 Subject: [PATCH 41/55] fix: fix gallary privacy policy violation crash (#75) (#522) Co-authored-by: Saeed Bashir Co-authored-by: Anton Yarmolenko <37253+rnr@users.noreply.github.com> --- OpenEdX/Info.plist | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/OpenEdX/Info.plist b/OpenEdX/Info.plist index e9bd32e58..95202c02b 100644 --- a/OpenEdX/Info.plist +++ b/OpenEdX/Info.plist @@ -45,5 +45,11 @@ 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. + NSPhotoLibraryAddUsageDescription + Allow access to your photo library so you can save photos in your gallery. From c632a014c3400cc190b45dda4520912ec42e1983 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Thu, 3 Oct 2024 13:10:17 +0300 Subject: [PATCH 42/55] Feat/migration to ios 18 (#525) * feat: migrate to ios 18 * fix: updrade ios version on tests * fix: CI --------- Co-authored-by: Volodymyr Chekyrta --- .github/workflows/unit_tests.yml | 2 +- .../Authorization.xcodeproj/project.pbxproj | 32 +- .../Presentation/Login/SignInView.swift | 4 +- .../Registration/SignUpView.swift | 4 +- .../Reset Password/ResetPasswordView.swift | 6 +- .../Presentation/Startup/StartupView.swift | 2 +- Core/Core.xcodeproj/project.pbxproj | 72 ++-- .../Config/AgreementConfig.swift | 7 +- Core/Core/Extensions/ViewExtension.swift | 23 -- Core/Core/Network/DownloadManager.swift | 11 +- .../View/Base/AppReview/AppReviewView.swift | 6 +- .../View/Base/FlexibleKeyboardInputView.swift | 4 +- Core/Core/View/Base/NavigationBar.swift | 2 +- Core/Core/View/Base/PickerMenu.swift | 2 +- .../View/Base/RefreshableScrollView.swift | 342 ------------------ .../Base/RefreshableScrollViewCompat.swift | 39 -- .../View/Base/RegistrationTextField.swift | 2 +- .../View/Base/VideoDownloadQualityView.swift | 2 +- Core/Core/View/Base/WebUnitView.swift | 4 +- Core/Core/View/Base/Webview/WebView.swift | 4 +- Core/Core/View/Base/Webview/WebViewHTML.swift | 4 +- Course/Course.xcodeproj/project.pbxproj | 41 +-- .../Downloads/DownloadsView.swift | 2 +- .../Outline/CourseOutlineView.swift | 15 +- .../Subviews/LessonLineProgressView.swift | 2 +- .../Unit/Subviews/LessonProgressView.swift | 2 +- .../Presentation/Video/SubtitlesView.swift | 2 +- Dashboard/Dashboard.xcodeproj/project.pbxproj | 32 +- .../Presentation/AllCoursesView.swift | 11 +- .../Elements/CategoryFilterView.swift | 2 +- .../Presentation/ListDashboardView.swift | 12 +- .../PrimaryCourseDashboardView.swift | 12 +- Discovery/Discovery.xcodeproj/project.pbxproj | 32 +- .../NativeDiscovery/CourseDetailsView.swift | 9 +- .../NativeDiscovery/DiscoveryView.swift | 102 +++--- .../UpdateViews/UpdateRecommendedView.swift | 2 +- .../UpdateViews/UpdateRequiredView.swift | 2 +- .../Discussion.xcodeproj/project.pbxproj | 32 +- .../Comments/Responses/ResponsesView.swift | 23 +- .../Comments/Thread/ThreadView.swift | 9 +- .../CreateNewThread/CreateNewThreadView.swift | 2 +- .../DiscussionTopicsView.swift | 9 +- .../Presentation/Posts/PostsView.swift | 17 +- Gemfile.lock | 54 +-- OpenEdX.xcodeproj/project.pbxproj | 24 +- Podfile | 14 +- Podfile.lock | 38 +- Profile/Profile.xcodeproj/project.pbxproj | 32 +- .../EditProfile/EditProfileView.swift | 2 +- .../EditProfile/ProfileBottomSheet.swift | 2 +- .../Presentation/Profile/ProfileView.swift | 13 +- .../Profile/UserProfile/UserProfileView.swift | 9 +- .../Settings/ManageAccountView.swift | 37 +- .../Presentation/Settings/SettingsView.swift | 2 +- .../Settings/VideoQualityView.swift | 2 +- .../Settings/VideoSettingsView.swift | 2 +- Theme/Theme.xcodeproj/project.pbxproj | 16 +- WhatsNew/WhatsNew.xcodeproj/project.pbxproj | 24 +- .../WhatsNew/Presentation/WhatsNewView.swift | 2 +- fastlane/Fastfile | 2 +- 60 files changed, 427 insertions(+), 794 deletions(-) delete mode 100644 Core/Core/View/Base/RefreshableScrollView.swift delete mode 100644 Core/Core/View/Base/RefreshableScrollViewCompat.swift diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index fa683726e..ee3806b98 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-14 + runs-on: macos-latest concurrency: # When running on develop, use the sha to allow all runs of this workflow to run concurrently. diff --git a/Authorization/Authorization.xcodeproj/project.pbxproj b/Authorization/Authorization.xcodeproj/project.pbxproj index 9e9990972..ffebcdf02 100644 --- a/Authorization/Authorization.xcodeproj/project.pbxproj +++ b/Authorization/Authorization.xcodeproj/project.pbxproj @@ -613,7 +613,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -641,7 +641,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -724,7 +724,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -751,7 +751,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -769,7 +769,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -787,7 +787,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -805,7 +805,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -823,7 +823,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -841,7 +841,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -859,7 +859,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.AuthorizationTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -948,7 +948,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1041,7 +1041,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1139,7 +1139,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1232,7 +1232,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1388,7 +1388,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1423,7 +1423,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index 4369f2fab..b3f8ef784 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -15,7 +15,7 @@ public struct SignInView: View { @State private var email: String = "" @State private var password: String = "" - @Environment (\.isHorizontal) private var isHorizontal + @Environment(\.isHorizontal) private var isHorizontal @ObservedObject private var viewModel: SignInViewModel @@ -209,7 +209,7 @@ public struct SignInView: View { } } } - .hideNavigationBar() + .navigationBarHidden(true) .ignoresSafeArea(.all, edges: .horizontal) .background(Theme.Colors.background.ignoresSafeArea(.all)) .onFirstAppear{ diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index 7ec2c8ba5..d63a3b9ef 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -14,7 +14,7 @@ public struct SignUpView: View { @State private var disclosureGroupOpen: Bool = false - @Environment (\.isHorizontal) private var isHorizontal + @Environment(\.isHorizontal) private var isHorizontal @ObservedObject private var viewModel: SignUpViewModel @@ -195,7 +195,7 @@ public struct SignUpView: View { } .ignoresSafeArea(.all, edges: .horizontal) .background(Theme.Colors.background.ignoresSafeArea(.all)) - .hideNavigationBar() + .navigationBarHidden(true) .onFirstAppear{ viewModel.trackScreenEvent() } diff --git a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift index 27dedad2f..2a993a7eb 100644 --- a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift +++ b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift @@ -15,7 +15,7 @@ public struct ResetPasswordView: View { @State private var isRecovered: Bool = false - @Environment (\.isHorizontal) private var isHorizontal + @Environment(\.isHorizontal) private var isHorizontal @ObservedObject private var viewModel: ResetPasswordViewModel @@ -173,10 +173,8 @@ public struct ResetPasswordView: View { } } .ignoresSafeArea(.all, edges: .horizontal) - .background(Theme.Colors.background.ignoresSafeArea(.all)) - - .hideNavigationBar() + .navigationBarHidden(true) } } } diff --git a/Authorization/Authorization/Presentation/Startup/StartupView.swift b/Authorization/Authorization/Presentation/Startup/StartupView.swift index 88551d824..59d5772b4 100644 --- a/Authorization/Authorization/Presentation/Startup/StartupView.swift +++ b/Authorization/Authorization/Presentation/Startup/StartupView.swift @@ -119,7 +119,7 @@ public struct StartupView: View { .frameLimit() } .navigationTitle(AuthLocalization.Startup.title) - .hideNavigationBar() + .navigationBarHidden(true) .padding(.all, isHorizontal ? 1 : 0) .background(Theme.Colors.background.ignoresSafeArea(.all)) .ignoresSafeArea(.keyboard, edges: .bottom) diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index d68bf217b..e8b75069a 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -14,8 +14,8 @@ 021D924828DC860C00ACC565 /* Data_UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D924728DC860C00ACC565 /* Data_UserProfile.swift */; }; 021D925028DC89D100ACC565 /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D924F28DC89D100ACC565 /* UserProfile.swift */; }; 021D925728DCF12900ACC565 /* AlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D925628DCF12900ACC565 /* AlertView.swift */; }; - 02228B312C2232D2009A5F28 /* IntExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02228B302C2232D2009A5F28 /* IntExtension.swift */; }; 022020462C11BB2200D15795 /* Data_CourseDates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022020452C11BB2200D15795 /* Data_CourseDates.swift */; }; + 02228B312C2232D2009A5F28 /* IntExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02228B302C2232D2009A5F28 /* IntExtension.swift */; }; 02280F5B294B4E6F0032823A /* Connectivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02280F5A294B4E6F0032823A /* Connectivity.swift */; }; 02284C182A3B1AE00007117F /* UIApplicationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02284C172A3B1AE00007117F /* UIApplicationExtension.swift */; }; 02286D162C106393005EEC8D /* CourseDates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02286D152C106393005EEC8D /* CourseDates.swift */; }; @@ -71,7 +71,6 @@ 028F9F39293A452B00DE65D0 /* ResetPassword.swift in Sources */ = {isa = PBXBuildFile; fileRef = 028F9F38293A452B00DE65D0 /* ResetPassword.swift */; }; 02935B732BCECAD000B22F66 /* Data_PrimaryEnrollment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02935B722BCECAD000B22F66 /* Data_PrimaryEnrollment.swift */; }; 02935B752BCEE6D600B22F66 /* PrimaryEnrollment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02935B742BCEE6D600B22F66 /* PrimaryEnrollment.swift */; }; - 0295C885299B99DD00ABE571 /* RefreshableScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0295C884299B99DD00ABE571 /* RefreshableScrollView.swift */; }; 029A13262C2457D9005FB830 /* OfflineProgress.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029A13252C2457D9005FB830 /* OfflineProgress.swift */; }; 029A13282C246AE6005FB830 /* OfflineSyncManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029A13272C246AE6005FB830 /* OfflineSyncManager.swift */; }; 029A132A2C2471DF005FB830 /* OfflineSyncRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029A13292C2471DF005FB830 /* OfflineSyncRepository.swift */; }; @@ -83,13 +82,12 @@ 02A4833829B8A8F900D33F33 /* CoreDataModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833629B8A8F800D33F33 /* CoreDataModel.xcdatamodeld */; }; 02A4833A29B8A9AB00D33F33 /* DownloadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833929B8A9AB00D33F33 /* DownloadManager.swift */; }; 02A4833C29B8C57800D33F33 /* DownloadView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833B29B8C57800D33F33 /* DownloadView.swift */; }; - 02AA27942C2C1B88006F5B6A /* ZipArchive in Frameworks */ = {isa = PBXBuildFile; productRef = 02AA27932C2C1B88006F5B6A /* ZipArchive */; }; 02A8C5812C05DBB4004B91FF /* Data_EnrollmentsStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A8C5802C05DBB4004B91FF /* Data_EnrollmentsStatus.swift */; }; + 02AA27942C2C1B88006F5B6A /* ZipArchive in Frameworks */ = {isa = PBXBuildFile; productRef = 02AA27932C2C1B88006F5B6A /* ZipArchive */; }; 02AFCC182AEFDB24000360F0 /* ThirdPartyMailClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AFCC172AEFDB24000360F0 /* ThirdPartyMailClient.swift */; }; 02AFCC1A2AEFDC18000360F0 /* ThirdPartyMailer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AFCC192AEFDC18000360F0 /* ThirdPartyMailer.swift */; }; 02B2B594295C5C7A00914876 /* Thread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B2B593295C5C7A00914876 /* Thread.swift */; }; 02B3E3B32930198600A50475 /* AVPlayerViewControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B3E3B22930198600A50475 /* AVPlayerViewControllerExtension.swift */; }; - 02B3F16E2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B3F16D2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift */; }; 02C917F029CDA99E00DBB8BD /* Data_Enrollments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C917EF29CDA99E00DBB8BD /* Data_Enrollments.swift */; }; 02CA59842BD7DDBE00D517AA /* DashboardConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CA59832BD7DDBE00D517AA /* DashboardConfig.swift */; }; 02CF46C829546AA200A698EE /* NoCachedDataError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CF46C729546AA200A698EE /* NoCachedDataError.swift */; }; @@ -219,8 +217,8 @@ 021D924728DC860C00ACC565 /* Data_UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_UserProfile.swift; sourceTree = ""; }; 021D924F28DC89D100ACC565 /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; 021D925628DCF12900ACC565 /* AlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertView.swift; sourceTree = ""; }; - 02228B302C2232D2009A5F28 /* IntExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntExtension.swift; sourceTree = ""; }; 022020452C11BB2200D15795 /* Data_CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_CourseDates.swift; sourceTree = ""; }; + 02228B302C2232D2009A5F28 /* IntExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntExtension.swift; sourceTree = ""; }; 02280F5A294B4E6F0032823A /* Connectivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.swift; sourceTree = ""; }; 02284C172A3B1AE00007117F /* UIApplicationExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationExtension.swift; sourceTree = ""; }; 02286D152C106393005EEC8D /* CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDates.swift; sourceTree = ""; }; @@ -275,7 +273,6 @@ 028F9F38293A452B00DE65D0 /* ResetPassword.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResetPassword.swift; sourceTree = ""; }; 02935B722BCECAD000B22F66 /* Data_PrimaryEnrollment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_PrimaryEnrollment.swift; sourceTree = ""; }; 02935B742BCEE6D600B22F66 /* PrimaryEnrollment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryEnrollment.swift; sourceTree = ""; }; - 0295C884299B99DD00ABE571 /* RefreshableScrollView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableScrollView.swift; sourceTree = ""; }; 029A13252C2457D9005FB830 /* OfflineProgress.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineProgress.swift; sourceTree = ""; }; 029A13272C246AE6005FB830 /* OfflineSyncManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineSyncManager.swift; sourceTree = ""; }; 029A13292C2471DF005FB830 /* OfflineSyncRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineSyncRepository.swift; sourceTree = ""; }; @@ -292,7 +289,6 @@ 02AFCC192AEFDC18000360F0 /* ThirdPartyMailer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThirdPartyMailer.swift; sourceTree = ""; }; 02B2B593295C5C7A00914876 /* Thread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thread.swift; sourceTree = ""; }; 02B3E3B22930198600A50475 /* AVPlayerViewControllerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayerViewControllerExtension.swift; sourceTree = ""; }; - 02B3F16D2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshableScrollViewCompat.swift; sourceTree = ""; }; 02C917EF29CDA99E00DBB8BD /* Data_Enrollments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_Enrollments.swift; sourceTree = ""; }; 02CA59832BD7DDBE00D517AA /* DashboardConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardConfig.swift; sourceTree = ""; }; 02CF46C729546AA200A698EE /* NoCachedDataError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoCachedDataError.swift; sourceTree = ""; }; @@ -778,8 +774,6 @@ 028CE96829858ECC00B6B1C3 /* FlexibleKeyboardInputView.swift */, 023A4DD3299E66BD006C0E48 /* OfflineSnackBarView.swift */, 021D925628DCF12900ACC565 /* AlertView.swift */, - 0295C884299B99DD00ABE571 /* RefreshableScrollView.swift */, - 02B3F16D2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift */, 0236F3B628F4351E0050F09B /* CourseButton.swift */, 0241666A28F5A78B00082765 /* HTMLFormattedText.swift */, 0282DA7228F98CC9003C3F07 /* WebUnitView.swift */, @@ -984,6 +978,7 @@ 0770DE0428D07831006D8A5D /* Sources */, 0770DE0528D07831006D8A5D /* Frameworks */, 0770DE0628D07831006D8A5D /* Resources */, + 49BAD0663C27D73B9115401F /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -1089,6 +1084,23 @@ shellPath = /bin/sh; shellScript = "if [[ -f \"${PODS_ROOT}/SwiftGen/bin/swiftgen\" ]]; then\n \"${PODS_ROOT}/SwiftGen/bin/swiftgen\"\nelse\n echo \"warning: SwiftGen is not installed. Run 'pod install --repo-update' to install it.\"\nfi\n"; }; + 49BAD0663C27D73B9115401F /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-App-Core/Pods-App-Core-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-App-Core/Pods-App-Core-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App-Core/Pods-App-Core-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; ED83AD5255805030E042D62A /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -1164,7 +1176,6 @@ 0236961F28F9A2F600EEF206 /* AuthEndpoint.swift in Sources */, 02B3E3B32930198600A50475 /* AVPlayerViewControllerExtension.swift in Sources */, BAD9CA332B28A8F300DE790A /* AjaxProvider.swift in Sources */, - 0295C885299B99DD00ABE571 /* RefreshableScrollView.swift in Sources */, A51CDBE72B6D21F2009B6D4E /* SegmentConfig.swift in Sources */, 0282DA7328F98CC9003C3F07 /* WebUnitView.swift in Sources */, E0D586362B314CD3009B4BA7 /* LogistrationBottomView.swift in Sources */, @@ -1270,7 +1281,6 @@ 028CE96929858ECC00B6B1C3 /* FlexibleKeyboardInputView.swift in Sources */, 027BD3A92909474200392132 /* KeyboardAvoidingViewControllerRepr.swift in Sources */, 02F98A7F28F81EE900DE94C0 /* Container+App.swift in Sources */, - 02B3F16E2AB489A400DDDD4E /* RefreshableScrollViewCompat.swift in Sources */, 0649879A2B4D69FF0071642A /* WebViewHTML.swift in Sources */, 02CA59842BD7DDBE00D517AA /* DashboardConfig.swift in Sources */, 0727877B28D24A1D002E9142 /* HeadersRedirectHandler.swift in Sources */, @@ -1410,7 +1420,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1438,7 +1448,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.2; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1525,7 +1535,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1552,7 +1562,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.2; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1573,7 +1583,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.2; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1595,7 +1605,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.2; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1617,7 +1627,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.2; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1639,7 +1649,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.2; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1660,7 +1670,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.2; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1681,7 +1691,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 16.2; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CoreTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1773,7 +1783,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1866,7 +1876,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1964,7 +1974,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2057,7 +2067,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2213,7 +2223,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2248,7 +2258,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -2323,7 +2333,7 @@ repositoryURL = "https://github.com/SvenTiigi/YouTubePlayerKit"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.8.0; + minimumVersion = 1.9.0; }; }; 02AA27922C2C1B61006F5B6A /* XCRemoteSwiftPackageReference "ZipArchive" */ = { @@ -2339,7 +2349,7 @@ repositoryURL = "https://github.com/BranchMetrics/ios-branch-sdk-spm"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.2.0; + minimumVersion = 3.6.3; }; }; BA8FA65E2AD574D700EA029A /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */ = { @@ -2347,7 +2357,7 @@ repositoryURL = "https://github.com/google/GoogleSignIn-iOS"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 7.0.0; + minimumVersion = 8.0.0; }; }; BA8FA6712AD6ABA300EA029A /* XCRemoteSwiftPackageReference "facebook-ios-sdk" */ = { @@ -2355,7 +2365,7 @@ repositoryURL = "https://github.com/facebook/facebook-ios-sdk"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 14.1.0; + minimumVersion = 16.3.1; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Core/Core/Configuration/Config/AgreementConfig.swift b/Core/Core/Configuration/Config/AgreementConfig.swift index 6dc4d518e..d6cfae197 100644 --- a/Core/Core/Configuration/Config/AgreementConfig.swift +++ b/Core/Core/Configuration/Config/AgreementConfig.swift @@ -42,12 +42,7 @@ public class AgreementConfig: NSObject { } private func completePath(url: String) -> String { - let langCode: String - if #available(iOS 16, *) { - langCode = Locale.current.language.languageCode?.identifier ?? "" - } else { - langCode = Locale.current.languageCode ?? "" - } + let langCode = Locale.current.language.languageCode?.identifier ?? "" if let supportedLanguages = supportedLanguages, !supportedLanguages.contains(langCode) { diff --git a/Core/Core/Extensions/ViewExtension.swift b/Core/Core/Extensions/ViewExtension.swift index 9db5e1087..8f6320658 100644 --- a/Core/Core/Extensions/ViewExtension.swift +++ b/Core/Core/Extensions/ViewExtension.swift @@ -179,29 +179,6 @@ public extension View { } } - func hideNavigationBar() -> some View { - if #available(iOS 16.0, *) { - return self.navigationBarHidden(true) - } else { - return self.introspect( - .navigationView(style: .stack), - on: .iOS(.v15...), - scope: .ancestor) { - $0.isNavigationBarHidden = true - } - } - } - - func hideScrollContentBackground() -> some View { - if #available(iOS 16.0, *) { - return self.scrollContentBackground(.hidden) - } else { - return self.onAppear { - UITextView.appearance().backgroundColor = .clear - } - } - } - func onRightSwipeGesture(perform action: @escaping () -> Void) -> some View { self.gesture( DragGesture(minimumDistance: 20, coordinateSpace: .local) diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index dd58deb4f..ed10c0a50 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -215,9 +215,14 @@ public class DownloadManager: DownloadManagerProtocol { // MARK: - Intents public func isLargeVideosSize(blocks: [CourseBlock]) -> Bool { - (blocks.reduce(0) { - $0 + Double($1.encodedVideo?.video(downloadQuality: downloadQuality)?.fileSize ?? 0) - } / 1024 / 1024 / 1024) > 1 + let totalSizeInBytes = blocks.reduce(0) { accumulator, block in + let videoSize = block.encodedVideo?.video(downloadQuality: downloadQuality)?.fileSize ?? 0 + return accumulator + Double(videoSize) + } + + let totalSizeInGB = totalSizeInBytes / (1024 * 1024 * 1024) + + return totalSizeInGB > 1 } public func getDownloadTasks() async -> [DownloadDataTask] { diff --git a/Core/Core/View/Base/AppReview/AppReviewView.swift b/Core/Core/View/Base/AppReview/AppReviewView.swift index 176c9707a..efe5df5bd 100644 --- a/Core/Core/View/Base/AppReview/AppReviewView.swift +++ b/Core/Core/View/Base/AppReview/AppReviewView.swift @@ -13,8 +13,8 @@ public struct AppReviewView: View { @ObservedObject private var viewModel: AppReviewViewModel - @Environment (\.isHorizontal) private var isHorizontal - @Environment (\.presentationMode) private var presentationMode + @Environment(\.isHorizontal) private var isHorizontal + @Environment(\.presentationMode) private var presentationMode public init(viewModel: AppReviewViewModel) { self.viewModel = viewModel @@ -77,7 +77,7 @@ public struct AppReviewView: View { .foregroundColor(Theme.Colors.textPrimary) .padding(.horizontal, 12) .padding(.vertical, 4) - .hideScrollContentBackground() + .scrollContentBackground(.hidden) .background( Theme.Shapes.textInputShape .fill(Theme.Colors.commentCellBackground) diff --git a/Core/Core/View/Base/FlexibleKeyboardInputView.swift b/Core/Core/View/Base/FlexibleKeyboardInputView.swift index 48a1119f5..fdf47efdc 100644 --- a/Core/Core/View/Base/FlexibleKeyboardInputView.swift +++ b/Core/Core/View/Base/FlexibleKeyboardInputView.swift @@ -12,7 +12,7 @@ public struct FlexibleKeyboardInputView: View { @State private var commentText: String = "" @State private var commentSize: CGFloat = .init(64) - @Environment (\.isHorizontal) private var isHorizontal + @Environment(\.isHorizontal) private var isHorizontal public var sendText: ((String) -> Void) private let hint: String @@ -54,7 +54,7 @@ public struct FlexibleKeyboardInputView: View { TextEditor(text: $commentText) .padding(.horizontal, 8) .foregroundColor(Theme.Colors.textInputTextColor) - .hideScrollContentBackground() + .scrollContentBackground(.hidden) .frame(maxHeight: commentSize) .background( Theme.InputFieldBackground( diff --git a/Core/Core/View/Base/NavigationBar.swift b/Core/Core/View/Base/NavigationBar.swift index 489d31236..9377067c7 100644 --- a/Core/Core/View/Base/NavigationBar.swift +++ b/Core/Core/View/Base/NavigationBar.swift @@ -23,7 +23,7 @@ public struct NavigationBar: View { private let rightButtonType: ButtonType? private let rightButtonAction: (() -> Void)? @Binding private var rightButtonIsActive: Bool - @Environment (\.isHorizontal) private var isHorizontal + @Environment(\.isHorizontal) private var isHorizontal public init(title: String, titleColor: Color = Theme.Colors.navigationBarTintColor, diff --git a/Core/Core/View/Base/PickerMenu.swift b/Core/Core/View/Base/PickerMenu.swift index cd3d6c49e..c425191c0 100644 --- a/Core/Core/View/Base/PickerMenu.swift +++ b/Core/Core/View/Base/PickerMenu.swift @@ -26,7 +26,7 @@ public struct PickerMenu: View { @State private var search: String = "" @State public var selectedItem: PickerItem = PickerItem(key: "", value: "") - @Environment (\.isHorizontal) private var isHorizontal + @Environment(\.isHorizontal) private var isHorizontal private let ipadPickerWidth: CGFloat = 300 private var items: [PickerItem] private let titleText: String diff --git a/Core/Core/View/Base/RefreshableScrollView.swift b/Core/Core/View/Base/RefreshableScrollView.swift deleted file mode 100644 index d09148528..000000000 --- a/Core/Core/View/Base/RefreshableScrollView.swift +++ /dev/null @@ -1,342 +0,0 @@ -// -// RefreshableScrollView.swift -// Core -// -// Created by  Stepanok Ivan on 14.02.2023. -// - -import SwiftUI - -// There are two type of positioning views - one that scrolls with the content, -// and one that stays fixed -private enum PositionType { - case fixed, moving -} - -// This struct is the currency of the Preferences, and has a type -// (fixed or moving) and the actual Y-axis value. -// It's Equatable because Swift requires it to be. -private struct Position: Equatable { - let type: PositionType - let y: CGFloat -} - -// This might seem weird, but it's necessary due to the funny nature of -// how Preferences work. We can't just store the last position and merge -// it with the next one - instead we have a queue of all the latest positions. -private struct PositionPreferenceKey: PreferenceKey { - typealias Value = [Position] - - static var defaultValue = [Position]() - - static func reduce(value: inout [Position], nextValue: () -> [Position]) { - value.append(contentsOf: nextValue()) - } -} - -private struct PositionIndicator: View { - let type: PositionType - - var body: some View { - GeometryReader { proxy in - // the View itself is an invisible Shape that fills as much as possible - Color.clear - // Compute the top Y position and emit it to the Preferences queue - .preference(key: PositionPreferenceKey.self, value: [Position(type: type, y: proxy.frame(in: .global).minY)]) - } - } -} - -// Callback that'll trigger once refreshing is done -public typealias RefreshComplete = () -> Void - -// The actual refresh action that's called once refreshing starts. It has the -// RefreshComplete callback to let the refresh action let the View know -// once it's done refreshing. -public typealias OnRefresh = (@escaping RefreshComplete) -> Void - -// The offset threshold. 68 is a good number, but you can play -// with it to your liking. -public let defaultRefreshThreshold: CGFloat = 68 - -// Tracks the state of the RefreshableScrollView - it's either: -// 1. waiting for a scroll to happen -// 2. has been primed by pulling down beyond THRESHOLD -// 3. is doing the refreshing. -public enum RefreshState { - case waiting, primed, loading -} - -// ViewBuilder for the custom progress View, that may render itself -// based on the current RefreshState. -public typealias RefreshProgressBuilder = (RefreshState) -> Progress - -// Default color of the rectangle behind the progress spinner -public let defaultLoadingViewBackgroundColor = Color(UIColor.clear) - -public struct RefreshableScrollView: View where Progress: View, Content: View { - let showsIndicators: Bool // if the ScrollView should show indicators - let shouldTriggerHapticFeedback: Bool // if key actions should trigger haptic feedback - let loadingViewBackgroundColor: Color - let threshold: CGFloat // what height do you have to pull down to trigger the refresh - let onRefresh: OnRefresh // the refreshing action - let progress: RefreshProgressBuilder // custom progress view - let content: () -> Content // the ScrollView content - @State private var offset: CGFloat = 0 - @State private var state = RefreshState.waiting // the current state - // Haptic Feedback - let finishedReloadingFeedbackGenerator = UIImpactFeedbackGenerator(style: .medium) - let primedFeedbackGenerator = UIImpactFeedbackGenerator(style: .heavy) - - // We use a custom constructor to allow for usage of a @ViewBuilder for the content - public init(showsIndicators: Bool = true, - shouldTriggerHapticFeedback: Bool = false, - loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor, - threshold: CGFloat = defaultRefreshThreshold, - onRefresh: @escaping OnRefresh, - @ViewBuilder progress: @escaping RefreshProgressBuilder, - @ViewBuilder content: @escaping () -> Content) { - self.showsIndicators = showsIndicators - self.shouldTriggerHapticFeedback = shouldTriggerHapticFeedback - self.loadingViewBackgroundColor = loadingViewBackgroundColor - self.threshold = threshold - self.onRefresh = onRefresh - self.progress = progress - self.content = content - } - - public var body: some View { - // The root view is a regular ScrollView - ScrollView(showsIndicators: showsIndicators) { - // The ZStack allows us to position the PositionIndicator, - // the content and the loading view, all on top of each other. - ZStack(alignment: .top) { - // The moving positioning indicator, that sits at the top - // of the ScrollView and scrolls down with the content - PositionIndicator(type: .moving) - .frame(height: 0) - - // Your ScrollView content. If we're loading, we want - // to keep it below the loading view, hence the alignmentGuide. - content() - .alignmentGuide(.top, computeValue: { _ in - (state == .loading) ? -threshold + max(0, offset) : 0 - }) - - // The loading view. It's offset to the top of the content unless we're loading. - ZStack { - Rectangle() - .foregroundColor(loadingViewBackgroundColor) - .frame(height: threshold) - progress(state) - }.offset(y: (state == .loading) ? -max(0, offset) : -threshold) - } - } - // Put a fixed PositionIndicator in the background so that we have - // a reference point to compute the scroll offset. - .background(PositionIndicator(type: .fixed)) - // Once the scrolling offset changes, we want to see if there should - // be a state change. - .onPreferenceChange(PositionPreferenceKey.self) { values in - DispatchQueue.main.async { - // Compute the offset between the moving and fixed PositionIndicators - let movingY = values.first { $0.type == .moving }?.y ?? 0 - let fixedY = values.first { $0.type == .fixed }?.y ?? 0 - offset = movingY - fixedY - if state != .loading { // If we're already loading, ignore everything - // Map the preference change action to the UI thread - // If the user pulled down below the threshold, prime the view - if offset > threshold && state == .waiting { - state = .primed - if shouldTriggerHapticFeedback { - self.primedFeedbackGenerator.impactOccurred() - } - - // If the view is primed and we've crossed the threshold again on the - // way back, trigger the refresh - } else if offset < threshold && state == .primed { - state = .loading - onRefresh { // trigger the refreshing callback - // once refreshing is done, smoothly move the loading view - // back to the offset position - withAnimation { - self.state = .waiting - } - if shouldTriggerHapticFeedback { - self.finishedReloadingFeedbackGenerator.impactOccurred() - } - } - } - } - } - } - } -} - -// Extension that uses default RefreshActivityIndicator so that you don't have to -// specify it every time. -public extension RefreshableScrollView where Progress == RefreshActivityIndicator { - init(showsIndicators: Bool = true, - loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor, - threshold: CGFloat = defaultRefreshThreshold, - onRefresh: @escaping OnRefresh, - @ViewBuilder content: @escaping () -> Content) { - self.init(showsIndicators: showsIndicators, - loadingViewBackgroundColor: loadingViewBackgroundColor, - threshold: threshold, - onRefresh: onRefresh, - progress: { state in - RefreshActivityIndicator(isAnimating: state == .loading) { - $0.hidesWhenStopped = false - } - }, - content: content) - } -} - -// Wraps a UIActivityIndicatorView as a loading spinner that works on all SwiftUI versions. -public struct RefreshActivityIndicator: UIViewRepresentable { - public typealias UIView = UIActivityIndicatorView - public var isAnimating: Bool = true - public var configuration = { (indicator: UIView) in } - - public init(isAnimating: Bool, configuration: ((UIView) -> Void)? = nil) { - self.isAnimating = isAnimating - if let configuration = configuration { - self.configuration = configuration - } - } - - public func makeUIView(context: UIViewRepresentableContext) -> UIView { - UIView() - } - - public func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext) { - isAnimating ? uiView.startAnimating() : uiView.stopAnimating() - configuration(uiView) - } -} - -#if compiler(>=5.5) -// Allows using RefreshableScrollView with an async block. -@available(iOS 15.0, *) -public extension RefreshableScrollView { - init(showsIndicators: Bool = true, - loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor, - threshold: CGFloat = defaultRefreshThreshold, - action: @escaping @Sendable () async -> Void, - @ViewBuilder progress: @escaping RefreshProgressBuilder, - @ViewBuilder content: @escaping () -> Content) { - self.init(showsIndicators: showsIndicators, - loadingViewBackgroundColor: loadingViewBackgroundColor, - threshold: threshold, - onRefresh: { refreshComplete in - Task { - await action() - refreshComplete() - } - }, - progress: progress, - content: content) - } -} -#endif - -public struct RefreshableCompat: ViewModifier where Progress: View { - private let showsIndicators: Bool - private let loadingViewBackgroundColor: Color - private let threshold: CGFloat - private let onRefresh: OnRefresh - private let progress: RefreshProgressBuilder - - public init(showsIndicators: Bool = true, - loadingViewBackgroundColor: Color = defaultLoadingViewBackgroundColor, - threshold: CGFloat = defaultRefreshThreshold, - onRefresh: @escaping OnRefresh, - @ViewBuilder progress: @escaping RefreshProgressBuilder) { - self.showsIndicators = showsIndicators - self.loadingViewBackgroundColor = loadingViewBackgroundColor - self.threshold = threshold - self.onRefresh = onRefresh - self.progress = progress - } - - public func body(content: Content) -> some View { - RefreshableScrollView(showsIndicators: showsIndicators, - loadingViewBackgroundColor: loadingViewBackgroundColor, - threshold: threshold, - onRefresh: onRefresh, - progress: progress) { - content - } - } -} - -#if compiler(>=5.5) -@available(iOS 15.0, *) -public extension List { - @ViewBuilder func refreshableCompat(showsIndicators: Bool = true, - loadingViewBackgroundColor: - Color = defaultLoadingViewBackgroundColor, - threshold: CGFloat = defaultRefreshThreshold, - onRefresh: @escaping OnRefresh, - @ViewBuilder progress: - @escaping RefreshProgressBuilder) -> some View { - if #available(macOS 12.0, *) { - self.refreshable { - await withCheckedContinuation { cont in - onRefresh { - cont.resume() - } - } - } - } else { - self.modifier(RefreshableCompat(showsIndicators: showsIndicators, - loadingViewBackgroundColor: loadingViewBackgroundColor, - threshold: threshold, - onRefresh: onRefresh, - progress: progress)) - } - } -} -#endif - -public extension View { - @ViewBuilder func refreshableCompat(showsIndicators: Bool = true, - loadingViewBackgroundColor: - Color = defaultLoadingViewBackgroundColor, - threshold: CGFloat = defaultRefreshThreshold, - onRefresh: @escaping OnRefresh, - @ViewBuilder progress: - @escaping RefreshProgressBuilder) -> some View { - self.modifier(RefreshableCompat(showsIndicators: showsIndicators, - loadingViewBackgroundColor: loadingViewBackgroundColor, - threshold: threshold, - onRefresh: onRefresh, - progress: progress)) - } -} - -struct ActivityIndicator: UIViewRepresentable { - public typealias UIView = UIActivityIndicatorView - public var isAnimating: Bool = true - public var configuration = { (indicator: UIView) in } - - public init(isAnimating: Bool, configuration: ((UIView) -> Void)? = nil) { - self.isAnimating = isAnimating - if let configuration = configuration { - self.configuration = configuration - } - } - - public func makeUIView(context: UIViewRepresentableContext) -> UIView { - let uiView = UIView() - uiView.startAnimating() - return uiView - } - - public func updateUIView(_ uiView: UIView, context: UIViewRepresentableContext) { -// isAnimating ? uiView.startAnimating() : uiView.stopAnimating() - configuration(uiView) - } - } diff --git a/Core/Core/View/Base/RefreshableScrollViewCompat.swift b/Core/Core/View/Base/RefreshableScrollViewCompat.swift deleted file mode 100644 index 768aa08b9..000000000 --- a/Core/Core/View/Base/RefreshableScrollViewCompat.swift +++ /dev/null @@ -1,39 +0,0 @@ -// -// RefreshableScrollViewCompat.swift -// Core -// -// Created by  Stepanok Ivan on 15.09.2023. -// - -import SwiftUI - -public struct RefreshableScrollViewCompat: View where Content: View { - private let content: () -> Content - private let action: () async -> Void - - public init(action: @escaping () async -> Void, @ViewBuilder content: @escaping () -> Content) { - self.action = action - self.content = content - } - - public var body: some View { - if #available(iOS 16.0, *) { - return ScrollView { - content() - }.refreshable { - Task { - await action() - } - } - } else { - return RefreshableScrollView(onRefresh: { done in - Task { - await action() - done() - } - }) { - content() - } - } - } -} diff --git a/Core/Core/View/Base/RegistrationTextField.swift b/Core/Core/View/Base/RegistrationTextField.swift index 45a8345ce..7cf4788d4 100644 --- a/Core/Core/View/Base/RegistrationTextField.swift +++ b/Core/Core/View/Base/RegistrationTextField.swift @@ -48,7 +48,7 @@ public struct RegistrationTextField: View { .padding(.vertical, 4) .foregroundColor(Theme.Colors.textInputTextColor) .frame(height: 100) - .hideScrollContentBackground() + .scrollContentBackground(.hidden) .background( Theme.Shapes.textInputShape .fill(Theme.Colors.textInputBackground) diff --git a/Core/Core/View/Base/VideoDownloadQualityView.swift b/Core/Core/View/Base/VideoDownloadQualityView.swift index 26cce95c4..2fd29240a 100644 --- a/Core/Core/View/Base/VideoDownloadQualityView.swift +++ b/Core/Core/View/Base/VideoDownloadQualityView.swift @@ -35,7 +35,7 @@ public struct VideoDownloadQualityView: View { private var analytics: CoreAnalytics private var router: BaseRouter private var isModal: Bool - @Environment (\.isHorizontal) private var isHorizontal + @Environment(\.isHorizontal) private var isHorizontal public init( downloadQuality: DownloadQuality, diff --git a/Core/Core/View/Base/WebUnitView.swift b/Core/Core/View/Base/WebUnitView.swift index 56d392abf..fda1e5040 100644 --- a/Core/Core/View/Base/WebUnitView.swift +++ b/Core/Core/View/Base/WebUnitView.swift @@ -104,9 +104,7 @@ public struct WebUnitView: View { ) } } - .introspect(.scrollView, on: .iOS(.v15...), customize: { scrollView in - scrollView.isScrollEnabled = false - }) + .scrollDisabled(true) .onChange(of: self.fileUrl, perform: { file in if file != "" { self.isFileOpen = true diff --git a/Core/Core/View/Base/Webview/WebView.swift b/Core/Core/View/Base/Webview/WebView.swift index f23097445..7ed3099a6 100644 --- a/Core/Core/View/Base/Webview/WebView.swift +++ b/Core/Core/View/Base/Webview/WebView.swift @@ -283,9 +283,7 @@ public struct WebView: UIViewRepresentable { let webView = WKWebView(frame: .zero, configuration: webViewConfig) #if DEBUG - if #available(iOS 16.4, *) { - webView.isInspectable = true - } + webView.isInspectable = true #endif webView.navigationDelegate = context.coordinator webView.uiDelegate = context.coordinator diff --git a/Core/Core/View/Base/Webview/WebViewHTML.swift b/Core/Core/View/Base/Webview/WebViewHTML.swift index 4db4d2abb..1d40cc082 100644 --- a/Core/Core/View/Base/Webview/WebViewHTML.swift +++ b/Core/Core/View/Base/Webview/WebViewHTML.swift @@ -36,9 +36,7 @@ public struct WebViewHtml: UIViewRepresentable { context.coordinator.webview = webView #if DEBUG - if #available(iOS 16.4, *) { - webView.isInspectable = true - } + webView.isInspectable = true #endif return webView } diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index bd2e05ca1..a04ee6650 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -7,8 +7,8 @@ objects = { /* Begin PBXBuildFile section */ - 02228B2F2C221412009A5F28 /* LargestDownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02228B2E2C221412009A5F28 /* LargestDownloadsView.swift */; }; - 02197DC52C1AFC0600CC8FF2 /* CalendarSyncStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EBC7542C19CFCF00BE182C /* CalendarSyncStatusView.swift */; }; + 02197DC52C1AFC0600CC8FF2 /* CalendarSyncStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EBC7542C19CFCF00BE182C /* CalendarSyncStatusView.swift */; }; + 02228B2F2C221412009A5F28 /* LargestDownloadsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02228B2E2C221412009A5F28 /* LargestDownloadsView.swift */; }; 02280F5E294B4FDA0032823A /* CourseCoreModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 02280F5C294B4FDA0032823A /* CourseCoreModel.xcdatamodeld */; }; 02280F60294B50030032823A /* CoursePersistenceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02280F5F294B50030032823A /* CoursePersistenceProtocol.swift */; }; 022C64D829ACEC48000F532B /* HandoutsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022C64D729ACEC48000F532B /* HandoutsView.swift */; }; @@ -333,8 +333,6 @@ 02B6B3B828E1D12900232911 /* Data */, 02B6B3B528E1D10700232911 /* Domain */, 02EAE2CA28E1F0A700529644 /* Presentation */, - 97CA95212B875EA200A9EDEA, - 97EA4D822B84EFA900663F58, 02B6B3B428E1C49400232911 /* Localizable.strings */, 02C355372C08DCD700501342 /* Localizable.stringsdict */, ); @@ -913,7 +911,6 @@ 02280F60294B50030032823A /* CoursePersistenceProtocol.swift in Sources */, 02454CAA2A2619B40043052A /* LessonProgressView.swift in Sources */, 975F475E2B6151FD00E5B031 /* CourseDatesMock.swift in Sources */, - DB7D6EB22ADFE9510036BB13 /* Data_CourseDates.swift in Sources */, 02FF6FAA2C20D56A00E44DD8 /* TotalDownloadedProgressView.swift in Sources */, 975F47602B615DA700E5B031 /* CourseStructureMock.swift in Sources */, 02280F5E294B4FDA0032823A /* CourseCoreModel.xcdatamodeld in Sources */, @@ -936,7 +933,6 @@ 0231124D28EDA804002588FB /* CourseUnitView.swift in Sources */, 027020FC28E7362100F54332 /* Data_CourseOutlineResponse.swift in Sources */, 02F71B4A2C1B163B00FF936A /* DownloadErrorAlertView.swift in Sources */, - DB7D6EB02ADFDA0E0036BB13 /* CourseDates.swift in Sources */, 067B7B532BED339200D1768F /* PlayerServiceProtocol.swift in Sources */, BAD9CA2D2B2736BB00DE790A /* LessonLineProgressView.swift in Sources */, 02197DC52C1AFC0600CC8FF2 /* CalendarSyncStatusView.swift in Sources */, @@ -970,7 +966,6 @@ 02E3803E2BFF9F0A00815AFA /* CourseProgressView.swift in Sources */, 022F8E162A1DFBC6008EFAB9 /* YouTubeVideoPlayerViewModel.swift in Sources */, 02B6B3BE28E1D15C00232911 /* CourseEndpoint.swift in Sources */, - 97EA4D862B85034D00663F58 /* CalendarManager.swift in Sources */, 02F71B4C2C1B200900FF936A /* DeviceStorageFullAlertView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1015,7 +1010,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; @@ -1036,7 +1031,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; @@ -1057,7 +1052,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; @@ -1078,7 +1073,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; @@ -1099,7 +1094,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; @@ -1120,7 +1115,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; @@ -1270,7 +1265,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1305,7 +1300,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1403,7 +1398,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1502,7 +1497,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1595,7 +1590,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1687,7 +1682,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1785,7 +1780,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1813,7 +1808,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; @@ -1899,7 +1894,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1926,7 +1921,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.CourseTests; diff --git a/Course/Course/Presentation/Downloads/DownloadsView.swift b/Course/Course/Presentation/Downloads/DownloadsView.swift index 93149bd4f..2926d82b0 100644 --- a/Course/Course/Presentation/Downloads/DownloadsView.swift +++ b/Course/Course/Presentation/Downloads/DownloadsView.swift @@ -15,7 +15,7 @@ public struct DownloadsView: View { // MARK: - Properties @Environment(\.dismiss) private var dismiss - @Environment (\.isHorizontal) private var isHorizontal + @Environment(\.isHorizontal) private var isHorizontal @StateObject private var viewModel: DownloadsViewModel var isSheet: Bool = true diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 7cd3a1f28..4464f9fe2 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -56,13 +56,7 @@ public struct CourseOutlineView: View { ZStack(alignment: .top) { GeometryReader { proxy in VStack(alignment: .center) { - RefreshableScrollViewCompat(action: { - await withTaskGroup(of: Void.self) { group in - group.addTask { - await viewModel.getCourseBlocks(courseID: courseID, withProgress: false) - } - } - }) { + ScrollView { DynamicOffsetView( coordinate: $coordinate, collapsed: $collapsed @@ -117,6 +111,13 @@ public struct CourseOutlineView: View { } .frameLimit(width: proxy.size.width) } + .refreshable { + await withTaskGroup(of: Void.self) { group in + group.addTask { + await viewModel.getCourseBlocks(courseID: courseID, withProgress: false) + } + } + } .onRightSwipeGesture { viewModel.router.back() } diff --git a/Course/Course/Presentation/Unit/Subviews/LessonLineProgressView.swift b/Course/Course/Presentation/Unit/Subviews/LessonLineProgressView.swift index b569a4f65..8233051d0 100644 --- a/Course/Course/Presentation/Unit/Subviews/LessonLineProgressView.swift +++ b/Course/Course/Presentation/Unit/Subviews/LessonLineProgressView.swift @@ -11,7 +11,7 @@ import Theme struct LessonLineProgressView: View { @ObservedObject var viewModel: CourseUnitViewModel - @Environment (\.isHorizontal) private var isHorizontal + @Environment(\.isHorizontal) private var isHorizontal init(viewModel: CourseUnitViewModel) { self.viewModel = viewModel diff --git a/Course/Course/Presentation/Unit/Subviews/LessonProgressView.swift b/Course/Course/Presentation/Unit/Subviews/LessonProgressView.swift index d1e848258..32badbf2a 100644 --- a/Course/Course/Presentation/Unit/Subviews/LessonProgressView.swift +++ b/Course/Course/Presentation/Unit/Subviews/LessonProgressView.swift @@ -12,7 +12,7 @@ import Theme struct LessonProgressView: View { @ObservedObject var viewModel: CourseUnitViewModel - @Environment (\.isHorizontal) private var isHorizontal + @Environment(\.isHorizontal) private var isHorizontal init(viewModel: CourseUnitViewModel) { self.viewModel = viewModel diff --git a/Course/Course/Presentation/Video/SubtitlesView.swift b/Course/Course/Presentation/Video/SubtitlesView.swift index 97dfe48ad..ef68d69b9 100644 --- a/Course/Course/Presentation/Video/SubtitlesView.swift +++ b/Course/Course/Presentation/Video/SubtitlesView.swift @@ -17,7 +17,7 @@ public struct Subtitle { public struct SubtitlesView: View { - @Environment (\.isHorizontal) private var isHorizontal + @Environment(\.isHorizontal) private var isHorizontal @ObservedObject private var viewModel: VideoPlayerViewModel diff --git a/Dashboard/Dashboard.xcodeproj/project.pbxproj b/Dashboard/Dashboard.xcodeproj/project.pbxproj index 817ab758a..2ae0ee9bd 100644 --- a/Dashboard/Dashboard.xcodeproj/project.pbxproj +++ b/Dashboard/Dashboard.xcodeproj/project.pbxproj @@ -546,7 +546,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -567,7 +567,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -588,7 +588,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -609,7 +609,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -630,7 +630,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -651,7 +651,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -743,7 +743,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -771,7 +771,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -857,7 +857,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -884,7 +884,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DashboardTests; @@ -1034,7 +1034,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1069,7 +1069,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1167,7 +1167,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1260,7 +1260,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1358,7 +1358,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1451,7 +1451,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Dashboard/Dashboard/Presentation/AllCoursesView.swift b/Dashboard/Dashboard/Presentation/AllCoursesView.swift index 4960ae42a..95671a487 100644 --- a/Dashboard/Dashboard/Presentation/AllCoursesView.swift +++ b/Dashboard/Dashboard/Presentation/AllCoursesView.swift @@ -14,7 +14,7 @@ public struct AllCoursesView: View { @ObservedObject private var viewModel: AllCoursesViewModel private let router: DashboardRouter - @Environment (\.isHorizontal) private var isHorizontal + @Environment(\.isHorizontal) private var isHorizontal private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } public init(viewModel: AllCoursesViewModel, router: DashboardRouter) { @@ -51,9 +51,7 @@ public struct AllCoursesView: View { VStack(alignment: .center) { learnTitleAndSearch() .frameLimit(width: proxy.size.width) - RefreshableScrollViewCompat(action: { - await viewModel.getCourses(page: 1, refresh: true) - }) { + ScrollView { CategoryFilterView(selectedOption: $viewModel.selectedMenu) .disabled(viewModel.fetchInProgress) .frameLimit(width: proxy.size.width) @@ -114,6 +112,11 @@ public struct AllCoursesView: View { } VStack {}.frame(height: 40) } + .refreshable { + Task { + await viewModel.getCourses(page: 1, refresh: true) + } + } .accessibilityAction {} } .padding(.top, 8) diff --git a/Dashboard/Dashboard/Presentation/Elements/CategoryFilterView.swift b/Dashboard/Dashboard/Presentation/Elements/CategoryFilterView.swift index a0b24625b..a46a41d46 100644 --- a/Dashboard/Dashboard/Presentation/Elements/CategoryFilterView.swift +++ b/Dashboard/Dashboard/Presentation/Elements/CategoryFilterView.swift @@ -44,7 +44,7 @@ enum CategoryOption: String, CaseIterable { struct CategoryFilterView: View { @Binding var selectedOption: CategoryOption - @Environment (\.colorScheme) var colorScheme + @Environment(\.colorScheme) var colorScheme var body: some View { ScrollView(.horizontal) { diff --git a/Dashboard/Dashboard/Presentation/ListDashboardView.swift b/Dashboard/Dashboard/Presentation/ListDashboardView.swift index a2678b3ff..d8f356e82 100644 --- a/Dashboard/Dashboard/Presentation/ListDashboardView.swift +++ b/Dashboard/Dashboard/Presentation/ListDashboardView.swift @@ -40,9 +40,7 @@ public struct ListDashboardView: View { // MARK: - Page body VStack(alignment: .center) { - RefreshableScrollViewCompat(action: { - await viewModel.getMyCourses(page: 1, refresh: true) - }) { + ScrollView { Group { LazyVStack(spacing: 0) { HStack { @@ -103,7 +101,13 @@ public struct ListDashboardView: View { } } .frameLimit(width: proxy.size.width) - }.accessibilityAction {} + } + .refreshable { + Task { + await viewModel.getMyCourses(page: 1, refresh: true) + } + } + .accessibilityAction {} }.padding(.top, 8) HStack { Spacer() diff --git a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift index d182a6a44..8b1a97e30 100644 --- a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift +++ b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift @@ -49,9 +49,7 @@ public struct PrimaryCourseDashboardView: View { Spacer(minLength: 50) switch selectedMenu { case .courses: - RefreshableScrollViewCompat(action: { - await viewModel.getEnrollments(showProgress: false) - }) { + ScrollView { ZStack(alignment: .topLeading) { if viewModel.fetchInProgress { VStack(alignment: .center) { @@ -148,7 +146,13 @@ public struct PrimaryCourseDashboardView: View { } } .frameLimit(width: proxy.size.width) - }.accessibilityAction {} + } + .refreshable { + Task { + await viewModel.getEnrollments(showProgress: false) + } + } + .accessibilityAction {} case .programs: programView } diff --git a/Discovery/Discovery.xcodeproj/project.pbxproj b/Discovery/Discovery.xcodeproj/project.pbxproj index c8958d0c0..73f72e424 100644 --- a/Discovery/Discovery.xcodeproj/project.pbxproj +++ b/Discovery/Discovery.xcodeproj/project.pbxproj @@ -614,7 +614,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -635,7 +635,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -656,7 +656,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -677,7 +677,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -698,7 +698,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -719,7 +719,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -812,7 +812,7 @@ INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -840,7 +840,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -927,7 +927,7 @@ INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -954,7 +954,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscoveryUnitTests; @@ -1105,7 +1105,7 @@ INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1141,7 +1141,7 @@ INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1240,7 +1240,7 @@ INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1340,7 +1340,7 @@ INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1434,7 +1434,7 @@ INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1527,7 +1527,7 @@ INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift index 80864b8fd..78b37f590 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift @@ -50,9 +50,7 @@ public struct CourseDetailsView: View { .accessibilityIdentifier("progress_bar") }.frame(width: proxy.size.width) } else { - RefreshableScrollViewCompat(action: { - await viewModel.getCourseDetail(courseID: courseID, withProgress: false) - }) { + ScrollView { VStack(alignment: .leading) { if let courseDetails = viewModel.courseDetails { @@ -139,6 +137,11 @@ public struct CourseDetailsView: View { } .frameLimit(width: proxy.size.width) } + .refreshable { + Task { + await viewModel.getCourseDetail(courseID: courseID, withProgress: false) + } + } .onRightSwipeGesture { viewModel.router.back() } diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift index 5816da080..d434dd05c 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift @@ -19,7 +19,7 @@ public struct DiscoveryView: View { private var sourceScreen: LogistrationSourceScreen - @Environment (\.isHorizontal) private var isHorizontal + @Environment(\.isHorizontal) private var isHorizontal @Environment(\.presentationMode) private var presentationMode private let discoveryNew: some View = VStack(alignment: .leading) { @@ -92,13 +92,7 @@ public struct DiscoveryView: View { .accessibilityLabel(DiscoveryLocalization.search) ZStack { - RefreshableScrollViewCompat(action: { - viewModel.totalPages = 1 - viewModel.nextPage = 1 - Task { - await viewModel.discovery(page: 1, withProgress: false) - } - }) { + ScrollView { LazyVStack(spacing: 0) { HStack { discoveryNew @@ -112,7 +106,7 @@ public struct DiscoveryView: View { model: course, type: .discovery, index: index, - cellsCount: viewModel.courses.count, + cellsCount: viewModel.courses.count, useRelativeDates: useRelativeDates ).padding(.horizontal, 24) .onAppear { @@ -143,60 +137,66 @@ public struct DiscoveryView: View { VStack {}.frame(height: 40) } .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) + }.refreshable { + viewModel.totalPages = 1 + viewModel.nextPage = 1 + Task { + await viewModel.discovery(page: 1, withProgress: false) } } } - }.padding(.top, 8) - - // MARK: - Offline mode SnackBar - OfflineSnackBarView( - connectivity: viewModel.connectivity, - reloadAction: { - await viewModel.discovery(page: 1, withProgress: false) - }) + }.accessibilityAction {} - // 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 + if !viewModel.userloggedIn { + LogistrationBottomView { buttonAction in + switch buttonAction { + case .signIn: + viewModel.router.showLoginScreen(sourceScreen: .discovery) + case .register: + viewModel.router.showRegisterScreen(sourceScreen: .discovery) } } } - } - .navigationBarHidden(sourceScreen != .startup) - .onFirstAppear { - if !(searchQuery.isEmpty) { - router.showDiscoverySearch(searchQuery: searchQuery) - searchQuery = "" + }.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) } - Task { - await viewModel.discovery(page: 1) - if case let .courseDetail(courseID, courseTitle) = sourceScreen { - viewModel.router.showCourseDetais(courseID: courseID, title: courseTitle) + .padding(.bottom, viewModel.connectivity.isInternetAvaliable + ? 0 : OfflineSnackBarView.height) + .transition(.move(edge: .bottom)) + .onAppear { + doAfter(Theme.Timeout.snackbarMessageLongTimeout) { + viewModel.errorMessage = nil } } - 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/UpdateViews/UpdateRecommendedView.swift b/Discovery/Discovery/Presentation/UpdateViews/UpdateRecommendedView.swift index c2716aa82..f017d4ac4 100644 --- a/Discovery/Discovery/Presentation/UpdateViews/UpdateRecommendedView.swift +++ b/Discovery/Discovery/Presentation/UpdateViews/UpdateRecommendedView.swift @@ -11,7 +11,7 @@ import Theme public struct UpdateRecommendedView: View { - @Environment (\.isHorizontal) private var isHorizontal + @Environment(\.isHorizontal) private var isHorizontal private let router: DiscoveryRouter private let config: ConfigProtocol diff --git a/Discovery/Discovery/Presentation/UpdateViews/UpdateRequiredView.swift b/Discovery/Discovery/Presentation/UpdateViews/UpdateRequiredView.swift index cbadd0ebf..2c3c53a79 100644 --- a/Discovery/Discovery/Presentation/UpdateViews/UpdateRequiredView.swift +++ b/Discovery/Discovery/Presentation/UpdateViews/UpdateRequiredView.swift @@ -11,7 +11,7 @@ import Theme public struct UpdateRequiredView: View { - @Environment (\.isHorizontal) private var isHorizontal + @Environment(\.isHorizontal) private var isHorizontal private let router: DiscoveryRouter private let config: ConfigProtocol private let showAccountLink: Bool diff --git a/Discussion/Discussion.xcodeproj/project.pbxproj b/Discussion/Discussion.xcodeproj/project.pbxproj index 657642ca2..a1b57703d 100644 --- a/Discussion/Discussion.xcodeproj/project.pbxproj +++ b/Discussion/Discussion.xcodeproj/project.pbxproj @@ -884,7 +884,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -918,7 +918,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1015,7 +1015,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1113,7 +1113,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1205,7 +1205,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1296,7 +1296,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1323,7 +1323,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; @@ -1344,7 +1344,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; @@ -1365,7 +1365,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; @@ -1386,7 +1386,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; @@ -1407,7 +1407,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; @@ -1428,7 +1428,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; @@ -1519,7 +1519,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1547,7 +1547,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; @@ -1632,7 +1632,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1659,7 +1659,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.DiscussionTests; diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index 91a5d9dda..6118d2172 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -19,7 +19,7 @@ public struct ResponsesView: View { @ObservedObject private var viewModel: ResponsesViewModel @State private var isShowProgress: Bool = true - + public init( commentID: String, viewModel: ResponsesViewModel, @@ -46,15 +46,7 @@ public struct ResponsesView: View { ScrollViewReader { scroll in VStack { ZStack(alignment: .top) { - RefreshableScrollViewCompat(action: { - viewModel.comments = [] - _ = await viewModel.getResponsesData( - commentID: commentID, - parentComment: parentComment, - page: 1, - refresh: true - ) - }) { + ScrollView { VStack { if let comments = viewModel.postComments { ParentCommentView( @@ -162,6 +154,17 @@ public struct ResponsesView: View { } .frameLimit(width: proxy.size.width) } + .refreshable { + viewModel.comments = [] + Task { + _ = await viewModel.getResponsesData( + commentID: commentID, + parentComment: parentComment, + page: 1, + refresh: true + ) + } + } if !(parentComment.closed || viewModel.isBlackedOut) { FlexibleKeyboardInputView( hint: DiscussionLocalization.Response.addComment, diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index 1d39962f2..0dbc40a3b 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -36,9 +36,7 @@ public struct ThreadView: View { ScrollViewReader { scroll in VStack { ZStack(alignment: .top) { - RefreshableScrollViewCompat(action: { - _ = await viewModel.getThreadData(thread: thread, page: 1, refresh: true) - }) { + ScrollView { VStack { if let comments = viewModel.postComments { ParentCommentView( @@ -157,6 +155,11 @@ public struct ThreadView: View { } .frameLimit(width: proxy.size.width) } + .refreshable { + Task { + _ = await viewModel.getThreadData(thread: thread, page: 1, refresh: true) + } + } if !(thread.closed || viewModel.isBlackedOut) { FlexibleKeyboardInputView( hint: DiscussionLocalization.Thread.addResponse, diff --git a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift index 7c676df8d..39aa77378 100644 --- a/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift +++ b/Discussion/Discussion/Presentation/CreateNewThread/CreateNewThreadView.swift @@ -141,7 +141,7 @@ public struct CreateNewThreadView: View { .padding(.horizontal, 10) .padding(.vertical, 10) .frame(height: 200) - .hideScrollContentBackground() + .scrollContentBackground(.hidden) .background( Theme.Shapes.textInputShape .fill(Theme.Colors.textInputBackground) diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift index 5964e41ab..77e23d8e1 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift @@ -37,9 +37,7 @@ public struct DiscussionTopicsView: View { GeometryReader { proxy in ZStack(alignment: .center) { VStack(alignment: .center) { - RefreshableScrollViewCompat(action: { - await viewModel.getTopics(courseID: self.courseID, withProgress: false) - }) { + ScrollView { DynamicOffsetView( coordinate: $coordinate, collapsed: $collapsed @@ -168,6 +166,11 @@ public struct DiscussionTopicsView: View { } }.frame(maxWidth: .infinity) + .refreshable { + Task { + await viewModel.getTopics(courseID: self.courseID, withProgress: false) + } + } }.padding(.top, 8) if viewModel.isShowProgress { ProgressBar(size: 40, lineWidth: 8) diff --git a/Discussion/Discussion/Presentation/Posts/PostsView.swift b/Discussion/Discussion/Presentation/Posts/PostsView.swift index 1ab2fc0fb..71912528e 100644 --- a/Discussion/Discussion/Presentation/Posts/PostsView.swift +++ b/Discussion/Discussion/Presentation/Posts/PostsView.swift @@ -102,13 +102,7 @@ public struct PostsView: View { Divider().offset(y: -8) } - RefreshableScrollViewCompat(action: { - viewModel.resetPosts() - _ = await viewModel.getPosts( - pageNumber: 1, - withProgress: false - ) - }) { + ScrollView { let posts = Array(viewModel.filteredPosts.enumerated()) if posts.count >= 1 { LazyVStack { @@ -209,6 +203,15 @@ public struct PostsView: View { } } } + .refreshable { + viewModel.resetPosts() + Task { + _ = await viewModel.getPosts( + pageNumber: 1, + withProgress: false + ) + } + } } .accessibilityAction {} .onRightSwipeGesture { diff --git a/Gemfile.lock b/Gemfile.lock index 3945f2d28..af3a47938 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -5,25 +5,25 @@ GEM base64 nkf rexml - addressable (2.8.6) - public_suffix (>= 2.0.2, < 6.0) + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.3.0) - aws-partitions (1.918.0) - aws-sdk-core (3.192.1) + aws-partitions (1.983.0) + aws-sdk-core (3.209.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.651.0) - aws-sigv4 (~> 1.8) + aws-sigv4 (~> 1.9) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.79.0) - aws-sdk-core (~> 3, >= 3.191.0) - aws-sigv4 (~> 1.1) - aws-sdk-s3 (1.147.0) - aws-sdk-core (~> 3, >= 3.192.0) + aws-sdk-kms (1.94.0) + aws-sdk-core (~> 3, >= 3.207.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.167.0) + aws-sdk-core (~> 3, >= 3.207.0) aws-sdk-kms (~> 1) - aws-sigv4 (~> 1.8) - aws-sigv4 (1.8.0) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.10.0) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) base64 (0.2.0) @@ -38,8 +38,8 @@ GEM domain_name (0.6.20240107) dotenv (2.8.1) emoji_regex (3.2.3) - excon (0.110.0) - faraday (1.10.3) + excon (0.112.0) + faraday (1.10.4) faraday-em_http (~> 1.0) faraday-em_synchrony (~> 1.0) faraday-excon (~> 1.1) @@ -60,15 +60,15 @@ GEM faraday-httpclient (1.0.1) faraday-multipart (1.0.4) multipart-post (~> 2) - faraday-net_http (1.0.1) + faraday-net_http (1.0.2) faraday-net_http_persistent (1.2.0) faraday-patron (1.0.0) faraday-rack (1.0.0) faraday-retry (1.0.3) - faraday_middleware (1.2.0) + faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.3.1) - fastlane (2.220.0) + fastlane (2.223.1) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -126,7 +126,7 @@ GEM google-apis-core (>= 0.11.0, < 2.a) google-apis-storage_v1 (0.31.0) google-apis-core (>= 0.11.0, < 2.a) - google-cloud-core (1.7.0) + google-cloud-core (1.7.1) google-cloud-env (>= 1.0, < 3.a) google-cloud-errors (~> 1.0) google-cloud-env (1.6.0) @@ -147,31 +147,31 @@ GEM os (>= 0.9, < 2.0) signet (>= 0.16, < 2.a) highline (2.0.3) - http-cookie (1.0.5) + http-cookie (1.0.7) domain_name (~> 0.5) httpclient (2.8.3) jmespath (1.6.2) json (2.7.2) - jwt (2.8.1) + jwt (2.9.3) base64 - mini_magick (4.12.0) + mini_magick (4.13.2) mini_mime (1.1.5) multi_json (1.15.0) - multipart-post (2.4.0) + multipart-post (2.4.1) nanaimo (0.3.0) naturally (2.2.1) nkf (0.2.0) optparse (0.5.0) os (1.1.4) plist (3.7.1) - public_suffix (5.0.5) + public_suffix (6.0.1) rake (13.2.1) 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.6) + rexml (3.3.8) rouge (2.0.7) ruby2_keywords (0.0.5) rubyzip (2.3.2) @@ -193,18 +193,18 @@ GEM tty-spinner (0.9.3) tty-cursor (~> 0.7) uber (0.1.0) - unicode-display_width (2.5.0) + unicode-display_width (2.6.0) word_wrap (1.0.0) xcode-install (2.8.1) claide (>= 0.9.1) fastlane (>= 2.1.0, < 3.0.0) - xcodeproj (1.24.0) + xcodeproj (1.25.1) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) nanaimo (~> 0.3.0) - rexml (~> 3.2.4) + rexml (>= 3.3.6, < 4.0) xcpretty (0.3.0) rouge (~> 2.0.7) xcpretty-travis-formatter (1.0.1) diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index dca913869..68414c4d2 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -769,7 +769,7 @@ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -860,7 +860,7 @@ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -957,7 +957,7 @@ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1048,7 +1048,7 @@ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1199,7 +1199,7 @@ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1236,7 +1236,7 @@ INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1290,7 +1290,7 @@ repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 10.26.0; + minimumVersion = 11.3.0; }; }; 14D912D12C25483F0077CCCE /* XCRemoteSwiftPackageReference "fullstory-swift-package-ios" */ = { @@ -1298,7 +1298,7 @@ repositoryURL = "https://github.com/fullstorydev/fullstory-swift-package-ios"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.49.0; + minimumVersion = 1.53.0; }; }; A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */ = { @@ -1306,7 +1306,7 @@ repositoryURL = "https://github.com/braze-inc/braze-segment-swift"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 2.2.0; + minimumVersion = 4.0.0; }; }; A5462D9D2B865713003B96A5 /* XCRemoteSwiftPackageReference "analytics-swift" */ = { @@ -1314,7 +1314,7 @@ repositoryURL = "https://github.com/segmentio/analytics-swift.git"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.5.3; + minimumVersion = 1.6.1; }; }; A5BD3E2E2B83B0F7006A8983 /* XCRemoteSwiftPackageReference "analytics-swift-firebase" */ = { @@ -1322,7 +1322,7 @@ repositoryURL = "https://github.com/segment-integrations/analytics-swift-firebase"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.3.5; + minimumVersion = 1.4.0; }; }; BA3042772B1F7147009B64B7 /* XCRemoteSwiftPackageReference "microsoft-authentication-library-for-objc" */ = { @@ -1330,7 +1330,7 @@ repositoryURL = "https://github.com/AzureAD/microsoft-authentication-library-for-objc"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 1.2.19; + minimumVersion = 1.5.1; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Podfile b/Podfile index 992beae02..4ce194c65 100644 --- a/Podfile +++ b/Podfile @@ -1,10 +1,10 @@ -platform :ios, '14.0' +platform :ios, '16.0' use_frameworks! :linkage => :static abstract_target "App" do #Code style - pod 'SwiftLint', '~> 0.54.0' + pod 'SwiftLint', '~> 0.57.0' #CodeGen for resources pod 'SwiftGen', '~> 6.6' @@ -17,14 +17,14 @@ abstract_target "App" do project './Core/Core.xcodeproj' workspace './Core/Core.xcodeproj' #Networking - pod 'Alamofire', '~> 5.7' + pod 'Alamofire', '~> 5.9' #Keychain - pod 'KeychainSwift', '~> 20.0' + pod 'KeychainSwift', '~> 24.0' #SwiftUI backward UIKit access #pod 'Introspect', '~> 0.6' - pod 'SwiftUIIntrospect', '~> 0.8' - pod 'Kingfisher', '~> 7.8' - pod 'Swinject', '2.8.3' + pod 'SwiftUIIntrospect', '~> 1.3' + pod 'Kingfisher', '~> 8.0' + pod 'Swinject', '2.9.1' end target "Authorization" do diff --git a/Podfile.lock b/Podfile.lock index ac4754acd..a79ae0c35 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,26 +1,26 @@ PODS: - - Alamofire (5.8.0) - - KeychainSwift (20.0.0) - - Kingfisher (7.9.1) + - Alamofire (5.9.1) + - KeychainSwift (24.0.0) + - Kingfisher (8.0.3) - Sourcery (1.8.0): - Sourcery/CLI-Only (= 1.8.0) - Sourcery/CLI-Only (1.8.0) - SwiftGen (6.6.3) - - SwiftLint (0.54.0) - - SwiftUIIntrospect (0.12.0) + - SwiftLint (0.57.0) + - SwiftUIIntrospect (1.3.0) - SwiftyMocky (4.2.0): - Sourcery (= 1.8.0) - - Swinject (2.8.3) + - Swinject (2.9.1) DEPENDENCIES: - - Alamofire (~> 5.7) - - KeychainSwift (~> 20.0) - - Kingfisher (~> 7.8) + - Alamofire (~> 5.9) + - KeychainSwift (~> 24.0) + - Kingfisher (~> 8.0) - SwiftGen (~> 6.6) - - SwiftLint (~> 0.54.0) - - SwiftUIIntrospect (~> 0.8) + - SwiftLint (~> 0.57.0) + - SwiftUIIntrospect (~> 1.3) - SwiftyMocky (from `https://github.com/MakeAWishFoundation/SwiftyMocky.git`, tag `4.2.0`) - - Swinject (= 2.8.3) + - Swinject (= 2.9.1) SPEC REPOS: trunk: @@ -44,16 +44,16 @@ CHECKOUT OPTIONS: :tag: 4.2.0 SPEC CHECKSUMS: - Alamofire: 0e92e751b3e9e66d7982db43919d01f313b8eb91 - KeychainSwift: 0ce6a4d13f7228054d1a71bb1b500448fb2ab837 - Kingfisher: 1d14e9f59cbe19389f591c929000332bf70efd32 + Alamofire: f36a35757af4587d8e4f4bfa223ad10be2422b8c + KeychainSwift: 007c4647486e4563adca839cf02cef00deb3b670 + Kingfisher: bbf78af014cc845cf9a799363f627b5212784165 Sourcery: 6f5fe49b82b7e02e8c65560cbd52e1be67a1af2e SwiftGen: 4993cbf71cbc4886f775e26f8d5c3a1188ec9f99 - SwiftLint: c1de071d9d08c8aba837545f6254315bc900e211 - SwiftUIIntrospect: 89f443402f701a9197e9e54e3c2ed00b10c32e6d + SwiftLint: eb47480d47c982481592c195c221d11013a679cc + SwiftUIIntrospect: fee9aa07293ee280373a591e1824e8ddc869ba5d SwiftyMocky: c5e96e4ff76ec6dbf5a5941aeb039b5a546954a0 - Swinject: 893c9a543000ac2f10ee4cbaf0933c6992c935d5 + Swinject: a827d508c6270da03ec74e558e728917a888fa9b -PODFILE CHECKSUM: 1b95af9ed204a9f360c00f6f9afa9955ad03b540 +PODFILE CHECKSUM: b3183e95d2b3bf330c512113a847f9a5485c23a5 COCOAPODS: 1.15.2 diff --git a/Profile/Profile.xcodeproj/project.pbxproj b/Profile/Profile.xcodeproj/project.pbxproj index 2401233cd..ae075f652 100644 --- a/Profile/Profile.xcodeproj/project.pbxproj +++ b/Profile/Profile.xcodeproj/project.pbxproj @@ -871,7 +871,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -906,7 +906,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1003,7 +1003,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1101,7 +1101,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1193,7 +1193,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1284,7 +1284,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1311,7 +1311,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; @@ -1332,7 +1332,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; @@ -1353,7 +1353,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; @@ -1374,7 +1374,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; @@ -1395,7 +1395,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; @@ -1416,7 +1416,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; @@ -1507,7 +1507,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1535,7 +1535,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; @@ -1620,7 +1620,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1647,7 +1647,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MACOSX_DEPLOYMENT_TARGET = 12.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.ProfileTests; diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift index cf432b238..7f6c415de 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift @@ -90,7 +90,7 @@ public struct EditProfileView: View { .padding(.horizontal, 12) .padding(.vertical, 4) .frame(height: 200) - .hideScrollContentBackground() + .scrollContentBackground(.hidden) .background( Theme.Shapes.textInputShape .fill(Theme.Colors.textInputBackground) diff --git a/Profile/Profile/Presentation/EditProfile/ProfileBottomSheet.swift b/Profile/Profile/Presentation/EditProfile/ProfileBottomSheet.swift index 802571018..481dd2a15 100644 --- a/Profile/Profile/Presentation/EditProfile/ProfileBottomSheet.swift +++ b/Profile/Profile/Presentation/EditProfile/ProfileBottomSheet.swift @@ -39,7 +39,7 @@ struct ProfileBottomSheet: View { private var removePhoto: () -> Void @Binding private var showingBottomSheet: Bool - @Environment (\.isHorizontal) private var isHorizontal + @Environment(\.isHorizontal) private var isHorizontal private var maxWidth: CGFloat { idiom == .pad || (idiom == .phone && isHorizontal) ? 330 : .infinity diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index 37a23c6de..390a4eec9 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -22,15 +22,16 @@ public struct ProfileView: View { GeometryReader { proxy in ZStack(alignment: .top) { // MARK: - Page Body - RefreshableScrollViewCompat( - action: { - await viewModel.getMyProfile(withProgress: false) - }, - content: { + ScrollView { content .frameLimit(width: proxy.size.width) } - ) + .refreshable { + Task { + await viewModel.getMyProfile(withProgress: false) + } + + } .accessibilityAction {} .padding(.top, 8) .navigationBarHidden(false) diff --git a/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift index 38078a834..cc5d2fc0f 100644 --- a/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift +++ b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift @@ -28,9 +28,7 @@ public struct UserProfileView: View { Theme.Colors.background .ignoresSafeArea() // MARK: - Page Body - RefreshableScrollViewCompat(action: { - await viewModel.getUserProfile(withProgress: false) - }) { + ScrollView { VStack { if viewModel.isShowProgress { ProgressBar(size: 40, lineWidth: 8) @@ -84,6 +82,11 @@ public struct UserProfileView: View { } .frameLimit(width: proxy.size.width) } + .refreshable { + Task { + await viewModel.getUserProfile(withProgress: false) + } + } .padding(.top, 8) .navigationBarHidden(false) .navigationBarBackButtonHidden(false) diff --git a/Profile/Profile/Presentation/Settings/ManageAccountView.swift b/Profile/Profile/Presentation/Settings/ManageAccountView.swift index 7f9475f76..f6c169da2 100644 --- a/Profile/Profile/Presentation/Settings/ManageAccountView.swift +++ b/Profile/Profile/Presentation/Settings/ManageAccountView.swift @@ -14,7 +14,7 @@ public struct ManageAccountView: View { @ObservedObject private var viewModel: ManageAccountViewModel - @Environment (\.isHorizontal) private var isHorizontal + @Environment(\.isHorizontal) private var isHorizontal public init(viewModel: ManageAccountViewModel) { self.viewModel = viewModel @@ -56,24 +56,25 @@ public struct ManageAccountView: View { } // MARK: - Page Body - RefreshableScrollViewCompat( - action: { - await viewModel.getMyProfile(withProgress: false) - }, - content: { - VStack(alignment: .leading, spacing: 12) { - if viewModel.isShowProgress { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 200) - .padding(.horizontal) - .accessibilityIdentifier("progress_bar") - } else { - userAvatar - editProfileButton - deleteAccount - } + ScrollView { + VStack(alignment: .leading, spacing: 12) { + if viewModel.isShowProgress { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 200) + .padding(.horizontal) + .accessibilityIdentifier("progress_bar") + } else { + userAvatar + editProfileButton + deleteAccount } - }) + } + } + .refreshable { + Task { + await viewModel.getMyProfile(withProgress: false) + } + } .frameLimit(width: proxy.size.width) .padding(.top, 24) .padding(.horizontal, isHorizontal ? 24 : 0) diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index e11926591..cc1eb0e45 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -15,7 +15,7 @@ public struct SettingsView: View { @ObservedObject private var viewModel: SettingsViewModel - @Environment (\.isHorizontal) private var isHorizontal + @Environment(\.isHorizontal) private var isHorizontal public init(viewModel: SettingsViewModel) { self.viewModel = viewModel diff --git a/Profile/Profile/Presentation/Settings/VideoQualityView.swift b/Profile/Profile/Presentation/Settings/VideoQualityView.swift index 3ad89ad78..829029111 100644 --- a/Profile/Profile/Presentation/Settings/VideoQualityView.swift +++ b/Profile/Profile/Presentation/Settings/VideoQualityView.swift @@ -14,7 +14,7 @@ public struct VideoQualityView: View { @ObservedObject private var viewModel: SettingsViewModel - @Environment (\.isHorizontal) private var isHorizontal + @Environment(\.isHorizontal) private var isHorizontal public init(viewModel: SettingsViewModel) { self.viewModel = viewModel diff --git a/Profile/Profile/Presentation/Settings/VideoSettingsView.swift b/Profile/Profile/Presentation/Settings/VideoSettingsView.swift index f54446cec..561c9cab1 100644 --- a/Profile/Profile/Presentation/Settings/VideoSettingsView.swift +++ b/Profile/Profile/Presentation/Settings/VideoSettingsView.swift @@ -13,7 +13,7 @@ public struct VideoSettingsView: View { @ObservedObject private var viewModel: SettingsViewModel - @Environment (\.isHorizontal) private var isHorizontal + @Environment(\.isHorizontal) private var isHorizontal public init(viewModel: SettingsViewModel) { self.viewModel = viewModel diff --git a/Theme/Theme.xcodeproj/project.pbxproj b/Theme/Theme.xcodeproj/project.pbxproj index e67d7eb3d..ea888a71a 100644 --- a/Theme/Theme.xcodeproj/project.pbxproj +++ b/Theme/Theme.xcodeproj/project.pbxproj @@ -509,7 +509,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -602,7 +602,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -700,7 +700,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -793,7 +793,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -891,7 +891,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -984,7 +984,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1140,7 +1140,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1175,7 +1175,7 @@ GENERATE_INFOPLIST_FILE = YES; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/WhatsNew/WhatsNew.xcodeproj/project.pbxproj b/WhatsNew/WhatsNew.xcodeproj/project.pbxproj index 62b52f41d..2cbc37bda 100644 --- a/WhatsNew/WhatsNew.xcodeproj/project.pbxproj +++ b/WhatsNew/WhatsNew.xcodeproj/project.pbxproj @@ -622,7 +622,7 @@ INFOPLIST_FILE = WhatsNew/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -661,7 +661,7 @@ INFOPLIST_FILE = WhatsNew/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -690,6 +690,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -710,6 +711,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -805,7 +807,7 @@ INFOPLIST_FILE = WhatsNew/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -835,6 +837,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -930,7 +933,7 @@ INFOPLIST_FILE = WhatsNew/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -960,6 +963,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1055,7 +1059,7 @@ INFOPLIST_FILE = WhatsNew/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1085,6 +1089,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1173,7 +1178,7 @@ INFOPLIST_FILE = WhatsNew/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1202,6 +1207,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1290,7 +1296,7 @@ INFOPLIST_FILE = WhatsNew/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1319,6 +1325,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -1407,7 +1414,7 @@ INFOPLIST_FILE = WhatsNew/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1436,6 +1443,7 @@ CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = L8PG7LC3Y3; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = org.openedx.app.WhatsNewTests; PRODUCT_NAME = "$(TARGET_NAME)"; diff --git a/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift b/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift index 4fbd03c4e..9a6ec09ce 100644 --- a/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift +++ b/WhatsNew/WhatsNew/Presentation/WhatsNewView.swift @@ -16,7 +16,7 @@ public struct WhatsNewView: View { @ObservedObject private var viewModel: WhatsNewViewModel - @Environment (\.isHorizontal) + @Environment(\.isHorizontal) private var isHorizontal @State var index = 0 diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 102e9a266..137d92894 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -15,7 +15,7 @@ update_fastlane before_all do xcodes( - version: '15.3', + version: '16.0', select_for_current_build_only: true, ) From 004666e59d961187e0ca13e8d38eaf62e85815f8 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Tue, 8 Oct 2024 13:25:25 +0200 Subject: [PATCH 43/55] fix: first batch of fix PRs to synchronise with upstream (#526) * Merge pull request #23 from edx/small-fix-for-downloading-cancelling fix: fixes for downloading * chore: fix for Xcode 16 and after merge * Merge pull request #24 from edx/2U/fix/download-states fix: [iOS] On Course "Home" tab the row height * fix: after merge, deleted IAP part fix: [iOS] On Course "Home" tab the row height * Merge pull request #25 from edx/2U/feat/primary-horizontal feat: Landscape mode Improvement * fix: removed IAP part * chore: remove snack bar error for course dates info API on course home (#27) * Merge pull request #28 from shafqat-muneer/Shafqat/LEARNER-10020-ErrorHandling feat: Course Level Error Handling for Empty States * chore: remove IAP part after merging --------- Co-authored-by: Anton Yarmolenko <37253+rnr@users.noreply.github.com> Co-authored-by: Saeed Bashir --- .../noVideos.imageset/Contents.json | 15 +++ .../noVideos.imageset/noVideos.svg | 3 + .../noAnnouncements.imageset/Contents.json | 15 +++ .../noAnnouncements.svg | 1 + .../noHandouts.imageset/Contents.json | 15 +++ .../noHandouts.imageset/noHandouts.svg | 3 + .../information.imageset/Contents.json | 15 +++ .../information.imageset/Vector.svg | 3 + .../Extensions/UIApplicationExtension.swift | 16 ++- Core/Core/Network/DownloadManager.swift | 46 +++---- Core/Core/SwiftGen/Assets.swift | 4 + Core/Core/View/Base/DynamicOffsetView.swift | 35 +++-- .../FullScreenErrorView.swift | 111 +++++++++------- Core/Core/View/Base/SnackBarView.swift | 2 +- .../Container/CourseContainerView.swift | 11 +- .../Container/CourseContainerViewModel.swift | 40 +++--- Course/Course/Presentation/CourseRouter.swift | 6 +- .../Presentation/Dates/CourseDatesView.swift | 36 +++++- .../Dates/CourseDatesViewModel.swift | 10 +- .../Handouts/HandoutsUpdatesDetailView.swift | 63 ++++++--- .../Presentation/Handouts/HandoutsView.swift | 120 ++++++++---------- .../Handouts/HandoutsViewModel.swift | 11 -- .../Presentation/Offline/OfflineView.swift | 7 +- .../Outline/CourseOutlineView.swift | 116 +++++++++++------ Course/Course/SwiftGen/Strings.swift | 12 +- Course/Course/en.lproj/Localizable.strings | 6 +- .../CourseContainerViewModelTests.swift | 23 ++-- .../Unit/CourseDateViewModelTests.swift | 8 +- .../Unit/HandoutsViewModelTests.swift | 8 -- .../Elements/PrimaryCardView.swift | 58 +++++++-- .../DiscussionTopicsView.swift | 79 +++++++----- .../DiscussionTopicsViewModel.swift | 8 +- Discussion/Discussion/SwiftGen/Strings.swift | 5 + .../Discussion/en.lproj/Localizable.strings | 2 + .../DiscussionTopicsViewModelTests.swift | 6 +- .../DeepLinkRouter/DeepLinkRouter.swift | 6 +- OpenEdX/Router.swift | 6 +- 37 files changed, 594 insertions(+), 337 deletions(-) create mode 100644 Core/Core/Assets.xcassets/CourseNavigationBar/noVideos.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/CourseNavigationBar/noVideos.imageset/noVideos.svg create mode 100644 Core/Core/Assets.xcassets/Handouts/noAnnouncements.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/Handouts/noAnnouncements.imageset/noAnnouncements.svg create mode 100644 Core/Core/Assets.xcassets/Handouts/noHandouts.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/Handouts/noHandouts.imageset/noHandouts.svg create mode 100644 Core/Core/Assets.xcassets/information.imageset/Contents.json create mode 100644 Core/Core/Assets.xcassets/information.imageset/Vector.svg diff --git a/Core/Core/Assets.xcassets/CourseNavigationBar/noVideos.imageset/Contents.json b/Core/Core/Assets.xcassets/CourseNavigationBar/noVideos.imageset/Contents.json new file mode 100644 index 000000000..48bb67ce1 --- /dev/null +++ b/Core/Core/Assets.xcassets/CourseNavigationBar/noVideos.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "noVideos.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Core/Core/Assets.xcassets/CourseNavigationBar/noVideos.imageset/noVideos.svg b/Core/Core/Assets.xcassets/CourseNavigationBar/noVideos.imageset/noVideos.svg new file mode 100644 index 000000000..07b71b885 --- /dev/null +++ b/Core/Core/Assets.xcassets/CourseNavigationBar/noVideos.imageset/noVideos.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/Handouts/noAnnouncements.imageset/Contents.json b/Core/Core/Assets.xcassets/Handouts/noAnnouncements.imageset/Contents.json new file mode 100644 index 000000000..d92ccd5d2 --- /dev/null +++ b/Core/Core/Assets.xcassets/Handouts/noAnnouncements.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "noAnnouncements.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Core/Core/Assets.xcassets/Handouts/noAnnouncements.imageset/noAnnouncements.svg b/Core/Core/Assets.xcassets/Handouts/noAnnouncements.imageset/noAnnouncements.svg new file mode 100644 index 000000000..750c81c0a --- /dev/null +++ b/Core/Core/Assets.xcassets/Handouts/noAnnouncements.imageset/noAnnouncements.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Core/Core/Assets.xcassets/Handouts/noHandouts.imageset/Contents.json b/Core/Core/Assets.xcassets/Handouts/noHandouts.imageset/Contents.json new file mode 100644 index 000000000..5a65a06fd --- /dev/null +++ b/Core/Core/Assets.xcassets/Handouts/noHandouts.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "noHandouts.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Core/Core/Assets.xcassets/Handouts/noHandouts.imageset/noHandouts.svg b/Core/Core/Assets.xcassets/Handouts/noHandouts.imageset/noHandouts.svg new file mode 100644 index 000000000..870076e9a --- /dev/null +++ b/Core/Core/Assets.xcassets/Handouts/noHandouts.imageset/noHandouts.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Assets.xcassets/information.imageset/Contents.json b/Core/Core/Assets.xcassets/information.imageset/Contents.json new file mode 100644 index 000000000..702db438f --- /dev/null +++ b/Core/Core/Assets.xcassets/information.imageset/Contents.json @@ -0,0 +1,15 @@ +{ + "images" : [ + { + "filename" : "Vector.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + }, + "properties" : { + "preserves-vector-representation" : true + } +} diff --git a/Core/Core/Assets.xcassets/information.imageset/Vector.svg b/Core/Core/Assets.xcassets/information.imageset/Vector.svg new file mode 100644 index 000000000..93a8bd6ce --- /dev/null +++ b/Core/Core/Assets.xcassets/information.imageset/Vector.svg @@ -0,0 +1,3 @@ + + + diff --git a/Core/Core/Extensions/UIApplicationExtension.swift b/Core/Core/Extensions/UIApplicationExtension.swift index f95bff70b..090eae3e3 100644 --- a/Core/Core/Extensions/UIApplicationExtension.swift +++ b/Core/Core/Extensions/UIApplicationExtension.swift @@ -10,12 +10,21 @@ import Theme public extension UIApplication { + var windows: [UIWindow]? { + let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene + return scene?.windows + } + + var window: UIWindow? { + windows?.first + } + var keyWindow: UIWindow? { - UIApplication.shared.windows.first { $0.isKeyWindow } + windows?.first { $0.isKeyWindow } } func endEditing(force: Bool = true) { - windows.forEach { $0.endEditing(force) } + windows?.forEach { $0.endEditing(force) } } class func topViewController( @@ -36,8 +45,7 @@ public extension UIApplication { } var windowInsets: UIEdgeInsets { - guard let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = windowScene.windows.first else { + guard let window = window else { return .zero } diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index ed10c0a50..6b6b30f19 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -253,9 +253,9 @@ 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 downloaded = await getDownloadTasksForCourse(courseId) let blocksForDelete = blocks.filter { block in - downloaded.first(where: { $0.blockId == block.id }) == nil + downloaded.first(where: { $0.blockId == block.id }) != nil } await deleteFile(blocks: blocksForDelete) downloaded.forEach { @@ -267,10 +267,10 @@ public class DownloadManager: DownloadManagerProtocol { public func cancelDownloading(task: DownloadDataTask) async throws { downloadRequest?.cancel() do { - try await persistence.deleteDownloadDataTask(id: task.id) - if let fileUrl = await fileUrl(for: task.id) { + if let fileUrl = fileUrl(for: task.id) { try FileManager.default.removeItem(at: fileUrl) } + try await persistence.deleteDownloadDataTask(id: task.id) currentDownloadEventPublisher.send(.canceled(task)) } catch { NSLog("Error deleting file: \(error.localizedDescription)") @@ -297,7 +297,8 @@ public class DownloadManager: DownloadManagerProtocol { public func deleteFile(blocks: [CourseBlock]) async { for block in blocks { do { - if let fileURL = await fileUrl(for: block.id) { + if let fileURL = fileUrl(for: block.id), + FileManager.default.fileExists(atPath: fileURL.path) { try FileManager.default.removeItem(at: fileURL) } try await persistence.deleteDownloadDataTask(id: block.id) @@ -442,7 +443,7 @@ public class DownloadManager: DownloadManagerProtocol { } private func downloadFileWithProgress(_ download: DownloadDataTask) throws { - guard let url = URL(string: download.url) else { + guard let url = URL(string: download.url), let folderURL = self.filesFolderUrl else { return } @@ -452,10 +453,14 @@ public class DownloadManager: DownloadManagerProtocol { resumeData: download.resumeData ) self.isDownloadingInProgress = true + let destination: DownloadRequest.Destination = { _, _ in + let file = folderURL.appendingPathComponent(download.fileName) + return (file, [.createIntermediateDirectories, .removePreviousFile]) + } if let resumeData = download.resumeData { - downloadRequest = AF.download(resumingWith: resumeData) + downloadRequest = AF.download(resumingWith: resumeData, to: destination) } else { - downloadRequest = AF.download(url) + downloadRequest = AF.download(url, to: destination) } downloadRequest?.downloadProgress { [weak self] prog in @@ -479,18 +484,15 @@ public class DownloadManager: DownloadManagerProtocol { return } } - if let data = data.value, let url = self.filesFolderUrl { - self.saveFile(fileName: download.fileName, data: data, folderURL: url) - self.persistence.updateDownloadState( - id: download.id, - state: .finished, - resumeData: nil - ) - self.currentDownloadTask?.state = .finished - self.currentDownloadEventPublisher.send(.finished(download)) - Task { - try? await self.newDownload() - } + self.persistence.updateDownloadState( + id: download.id, + state: .finished, + resumeData: nil + ) + self.currentDownloadTask?.state = .finished + self.currentDownloadEventPublisher.send(.finished(download)) + Task { + try? await self.newDownload() } } } @@ -567,10 +569,10 @@ public class DownloadManager: DownloadManagerProtocol { private func cancel(tasks: [DownloadDataTask]) async { for task in tasks { do { - try await persistence.deleteDownloadDataTask(id: task.id) - if let fileUrl = await fileUrl(for: task.id) { + if let fileUrl = fileUrl(for: task.id) { try FileManager.default.removeItem(at: fileUrl) } + try await persistence.deleteDownloadDataTask(id: task.id) } catch { debugLog("Error deleting file: \(error.localizedDescription)") } diff --git a/Core/Core/SwiftGen/Assets.swift b/Core/Core/SwiftGen/Assets.swift index f5bede856..48ebcc9cc 100644 --- a/Core/Core/SwiftGen/Assets.swift +++ b/Core/Core/SwiftGen/Assets.swift @@ -44,6 +44,7 @@ public enum CoreAssets { public static let downloads = ImageAsset(name: "downloads") public static let home = ImageAsset(name: "home") public static let more = ImageAsset(name: "more") + public static let noVideos = ImageAsset(name: "noVideos") public static let videos = ImageAsset(name: "videos") public static let dashboardEmptyPage = ImageAsset(name: "DashboardEmptyPage") public static let addComment = ImageAsset(name: "addComment") @@ -72,6 +73,8 @@ public enum CoreAssets { public static let stopDownloading = ImageAsset(name: "stopDownloading") public static let announcements = ImageAsset(name: "announcements") public static let handouts = ImageAsset(name: "handouts") + public static let noAnnouncements = ImageAsset(name: "noAnnouncements") + public static let noHandouts = ImageAsset(name: "noHandouts") public static let dashboard = ImageAsset(name: "dashboard") public static let discovery = ImageAsset(name: "discovery") public static let learn = ImageAsset(name: "learn") @@ -111,6 +114,7 @@ public enum CoreAssets { public static let favorite = ImageAsset(name: "favorite") public static let finishedSequence = ImageAsset(name: "finished_sequence") public static let goodWork = ImageAsset(name: "goodWork") + public static let information = ImageAsset(name: "information") public static let learnEmpty = ImageAsset(name: "learn_empty") public static let airmail = ImageAsset(name: "airmail") public static let defaultMail = ImageAsset(name: "defaultMail") diff --git a/Core/Core/View/Base/DynamicOffsetView.swift b/Core/Core/View/Base/DynamicOffsetView.swift index 2c4d47fe1..da1bdf984 100644 --- a/Core/Core/View/Base/DynamicOffsetView.swift +++ b/Core/Core/View/Base/DynamicOffsetView.swift @@ -24,16 +24,19 @@ public struct DynamicOffsetView: View { @Binding private var coordinate: CGFloat @Binding private var collapsed: Bool + @Binding private var viewHeight: CGFloat @State private var collapseHeight: CGFloat = .zero @Environment(\.isHorizontal) private var isHorizontal public init( coordinate: Binding, - collapsed: Binding + collapsed: Binding, + viewHeight: Binding ) { self._coordinate = coordinate self._collapsed = collapsed + self._viewHeight = viewHeight } public var body: some View { @@ -56,28 +59,36 @@ public struct DynamicOffsetView: View { } ) .onAppear { - changeCollapsedHeight() + changeCollapsedHeight(collapsed: collapsed, isHorizontal: isHorizontal) } .onChange(of: collapsed) { collapsed in if !collapsed { - changeCollapsedHeight() + changeCollapsedHeight(collapsed: collapsed, isHorizontal: isHorizontal) } } .onChange(of: isHorizontal) { isHorizontal in if isHorizontal { collapsed = true } - changeCollapsedHeight() + changeCollapsedHeight(collapsed: collapsed, isHorizontal: isHorizontal) } } - private func changeCollapsedHeight() { - collapseHeight = idiom == .pad - ? padHeight - : ( - collapsed - ? (isHorizontal ? collapsedHorizontalHeight : collapsedVerticalHeight) - : expandedHeight - ) + private func changeCollapsedHeight( + collapsed: Bool, + isHorizontal: Bool + ) { + if idiom == .pad { + collapseHeight = padHeight + } else if collapsed { + if isHorizontal { + collapseHeight = collapsedHorizontalHeight + } else { + collapseHeight = collapsedVerticalHeight + } + } else { + collapseHeight = 240 + } + viewHeight = collapseHeight } } diff --git a/Core/Core/View/Base/FullScreenErrorView/FullScreenErrorView.swift b/Core/Core/View/Base/FullScreenErrorView/FullScreenErrorView.swift index dc5893621..4e17c0626 100644 --- a/Core/Core/View/Base/FullScreenErrorView/FullScreenErrorView.swift +++ b/Core/Core/View/Base/FullScreenErrorView/FullScreenErrorView.swift @@ -10,10 +10,11 @@ import Theme public struct FullScreenErrorView: View { - public enum ErrorType { + public enum ErrorType: Equatable { case noInternet case noInternetWithReload case generic + case noContent(_ message: String, image: SwiftUI.Image) } private let errorType: ErrorType @@ -34,57 +35,69 @@ public struct FullScreenErrorView: View { } public var body: some View { - GeometryReader { proxy in - VStack(spacing: 28) { - Spacer() - switch errorType { - case .noInternet, .noInternetWithReload: - CoreAssets.noWifi.swiftUIImage - .renderingMode(.template) - .foregroundStyle(Color.primary) - .scaledToFit() - - 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, 50) - case .generic: - CoreAssets.notAvaliable.swiftUIImage - .renderingMode(.template) - .foregroundStyle(Color.primary) - .scaledToFit() - - Text(CoreLocalization.View.Snackbar.tryAgainBtn) - .font(Theme.Fonts.titleLarge) - .foregroundColor(Theme.Colors.textPrimary) - - Text(CoreLocalization.Error.unknownError) - .font(Theme.Fonts.bodyLarge) - .foregroundColor(Theme.Colors.textPrimary) - .multilineTextAlignment(.center) - .padding(.horizontal, 50) - } + VStack(spacing: 20) { + Spacer() + switch errorType { + case .noContent(let message, image: let image): + image + .resizable() + .renderingMode(.template) + .foregroundStyle(Theme.Colors.textSecondary) + .aspectRatio(contentMode: .fit) + .frame(maxWidth: 72, maxHeight: 80) - if errorType != .noInternet { - UnitButtonView( - type: .reload, - action: { - self.action() - } - ) - } - Spacer() + Text(message) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + .multilineTextAlignment(.center) + .padding(.horizontal, 50) + case .noInternet, + .noInternetWithReload: + CoreAssets.noWifi.swiftUIImage + .renderingMode(.template) + .foregroundStyle(Theme.Colors.textSecondary) + .scaledToFit() + + Text(CoreLocalization.Error.Internet.noInternetTitle) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + + Text(CoreLocalization.Error.Internet.noInternetDescription) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + .multilineTextAlignment(.center) + .padding(.horizontal, 50) + case .generic: + CoreAssets.notAvaliable.swiftUIImage + .renderingMode(.template) + .foregroundStyle(Theme.Colors.textSecondary) + .scaledToFit() + + Text(CoreLocalization.View.Snackbar.tryAgainBtn) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + + Text(CoreLocalization.Error.unknownError) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + .multilineTextAlignment(.center) + .padding(.horizontal, 50) + + } + if errorType == .noInternetWithReload || errorType == .generic { + UnitButtonView( + type: .reload, + action: { + self.action() + } + ) } - .frame(maxWidth: .infinity, maxHeight: proxy.size.height) - .background( - Theme.Colors.background - ) + Spacer() } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + Theme.Colors.background + ) } } diff --git a/Core/Core/View/Base/SnackBarView.swift b/Core/Core/View/Base/SnackBarView.swift index 14ed079f4..fc61351de 100644 --- a/Core/Core/View/Base/SnackBarView.swift +++ b/Core/Core/View/Base/SnackBarView.swift @@ -14,7 +14,7 @@ public struct SnackBarView: View { var action: (() -> Void)? private var safeArea: CGFloat { - UIApplication.shared.windows.first { $0.isKeyWindow }?.safeAreaInsets.bottom ?? 0 + UIApplication.shared.keyWindow?.safeAreaInsets.bottom ?? 0 } private let minHeight: CGFloat = 50 diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 0b7c36ca1..65ca348d2 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -24,6 +24,7 @@ public struct CourseContainerView: View { @State private var coordinate: CGFloat = .zero @State private var lastCoordinate: CGFloat = .zero @State private var collapsed: Bool = false + @State private var viewHeight: CGFloat = .zero @Environment(\.isHorizontal) private var isHorizontal @Namespace private var animationNamespace private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } @@ -93,6 +94,7 @@ public struct CourseContainerView: View { selection: $viewModel.selection, coordinate: $coordinate, collapsed: $collapsed, + viewHeight: $viewHeight, dateTabIndex: CourseTab.dates.rawValue ) } else { @@ -191,6 +193,7 @@ public struct CourseContainerView: View { selection: $viewModel.selection, coordinate: $coordinate, collapsed: $collapsed, + viewHeight: $viewHeight, dateTabIndex: CourseTab.dates.rawValue ) .tabItem { @@ -208,6 +211,7 @@ public struct CourseContainerView: View { selection: $viewModel.selection, coordinate: $coordinate, collapsed: $collapsed, + viewHeight: $viewHeight, dateTabIndex: CourseTab.dates.rawValue ) .tabItem { @@ -221,8 +225,8 @@ public struct CourseContainerView: View { courseID: courseID, coordinate: $coordinate, collapsed: $collapsed, - viewModel: Container.shared.resolve(CourseDatesViewModel.self, - arguments: courseID, title)! + viewHeight: $viewHeight, + viewModel: courseDatesViewModel ) .tabItem { tab.image @@ -235,6 +239,7 @@ public struct CourseContainerView: View { courseID: courseID, coordinate: $coordinate, collapsed: $collapsed, + viewHeight: $viewHeight, viewModel: viewModel ) .tabItem { @@ -248,6 +253,7 @@ public struct CourseContainerView: View { courseID: courseID, coordinate: $coordinate, collapsed: $collapsed, + viewHeight: $viewHeight, viewModel: Container.shared.resolve(DiscussionTopicsViewModel.self, argument: title)!, router: Container.shared.resolve(DiscussionRouter.self)! @@ -263,6 +269,7 @@ public struct CourseContainerView: View { courseID: courseID, coordinate: $coordinate, collapsed: $collapsed, + viewHeight: $viewHeight, viewModel: Container.shared.resolve(HandoutsViewModel.self, argument: courseID)! ) .tabItem { diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index a15a53d98..b8ddea494 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -188,6 +188,15 @@ public class CourseContainerViewModel: BaseCourseViewModel { ) } + @MainActor + func getCourseStructure(courseID: String) async throws -> CourseStructure? { + if isInternetAvaliable { + return try await interactor.getCourseBlocks(courseID: courseID) + } else { + return try await interactor.getLoadedCourseBlocks(courseID: courseID) + } + } + @MainActor func getCourseBlocks(courseID: String, withProgress: Bool = true) async { guard let courseStart, courseStart < Date() else { return } @@ -195,34 +204,29 @@ public class CourseContainerViewModel: BaseCourseViewModel { isShowProgress = withProgress isShowRefresh = !withProgress do { + let courseStructure = try await getCourseStructure(courseID: courseID) + await setDownloadsStates(courseStructure: courseStructure) + self.courseStructure = courseStructure + if isInternetAvaliable { - courseStructure = try await interactor.getCourseBlocks(courseID: courseID) NotificationCenter.default.post(name: .getCourseDates, object: courseID) - isShowProgress = false - isShowRefresh = false if let courseStructure { try await getResumeBlock( courseID: courseID, courseStructure: courseStructure ) } - } else { - courseStructure = try await interactor.getLoadedCourseBlocks(courseID: courseID) } courseVideosStructure = interactor.getCourseVideoBlocks(fullStructure: courseStructure!) - await setDownloadsStates() await getDownloadingProgress() isShowProgress = false isShowRefresh = false - } catch let error { + } catch { isShowProgress = false isShowRefresh = false - if error.isInternetError || error is NoCachedDataError { - errorMessage = CoreLocalization.Error.slowOrNoInternetConnection - } else { - errorMessage = CoreLocalization.Error.unknownError - } + courseStructure = nil + courseVideosStructure = nil } } @@ -234,11 +238,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { self.courseDeadlineInfo = courseDeadlineInfo } } catch let error { - if error.isInternetError || error is NoCachedDataError { - errorMessage = CoreLocalization.Error.slowOrNoInternetConnection - } else { - errorMessage = CoreLocalization.Error.unknownError - } + debugLog(error.localizedDescription) } } @@ -771,7 +771,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { func stopAllDownloads() async { do { try await manager.cancelAllDownloading() - await setDownloadsStates() + await setDownloadsStates(courseStructure: self.courseStructure) await getDownloadingProgress() } catch { errorMessage = CoreLocalization.Error.unknownError @@ -855,7 +855,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { } @MainActor - func setDownloadsStates() async { + func setDownloadsStates(courseStructure: CourseStructure?) async { guard let course = courseStructure else { return } courseDownloadTasks = await manager.getDownloadTasksForCourse(course.id) downloadableVerticals = [] @@ -1080,7 +1080,7 @@ public class CourseContainerViewModel: BaseCourseViewModel { if case .progress = state { return } Task(priority: .background) { debugLog(state, "--- state ---") - await self.setDownloadsStates() + await self.setDownloadsStates(courseStructure: self.courseStructure) await self.getDownloadingProgress() } } diff --git a/Course/Course/Presentation/CourseRouter.swift b/Course/Course/Presentation/CourseRouter.swift index f198f2fb5..a9b33ef79 100644 --- a/Course/Course/Presentation/CourseRouter.swift +++ b/Course/Course/Presentation/CourseRouter.swift @@ -46,7 +46,8 @@ public protocol CourseRouter: BaseRouter { handouts: String?, announcements: [CourseUpdate]?, router: Course.CourseRouter, - cssInjector: CSSInjector + cssInjector: CSSInjector, + type: HandoutsItemType ) func showCourseComponent( @@ -105,7 +106,8 @@ public class CourseRouterMock: BaseRouterMock, CourseRouter { handouts: String?, announcements: [CourseUpdate]?, router: Course.CourseRouter, - cssInjector: CSSInjector + cssInjector: CSSInjector, + type: HandoutsItemType ) {} public func showCourseComponent( diff --git a/Course/Course/Presentation/Dates/CourseDatesView.swift b/Course/Course/Presentation/Dates/CourseDatesView.swift index 5ec069ea5..7283f516d 100644 --- a/Course/Course/Presentation/Dates/CourseDatesView.swift +++ b/Course/Course/Presentation/Dates/CourseDatesView.swift @@ -19,16 +19,19 @@ public struct CourseDatesView: View { private var viewModel: CourseDatesViewModel @Binding private var coordinate: CGFloat @Binding private var collapsed: Bool + @Binding private var viewHeight: CGFloat public init( courseID: String, coordinate: Binding, collapsed: Binding, + viewHeight: Binding, viewModel: CourseDatesViewModel ) { self.courseID = courseID self._coordinate = coordinate self._collapsed = collapsed + self._viewHeight = viewHeight self._viewModel = StateObject(wrappedValue: viewModel) } @@ -46,10 +49,33 @@ public struct CourseDatesView: View { viewModel: viewModel, coordinate: $coordinate, collapsed: $collapsed, + viewHeight: $viewHeight, courseDates: courseDates, courseID: courseID ) .padding(.top, 10) + } else { + GeometryReader { proxy in + VStack { + ScrollView { + DynamicOffsetView( + coordinate: $coordinate, + collapsed: $collapsed, + viewHeight: $viewHeight + ) + + FullScreenErrorView( + type: .noContent( + CourseLocalization.Error.courseDateUnavailable, + image: CoreAssets.information.swiftUIImage + ) + ) + .frame(maxWidth: .infinity) + .frame(height: proxy.size.height - viewHeight) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } } } @@ -154,6 +180,7 @@ struct CourseDateListView: View { @State private var isExpanded = false @Binding var coordinate: CGFloat @Binding var collapsed: Bool + @Binding var viewHeight: CGFloat var courseDates: CourseDates let courseID: String @@ -163,7 +190,8 @@ struct CourseDateListView: View { ScrollView { DynamicOffsetView( coordinate: $coordinate, - collapsed: $collapsed + collapsed: $collapsed, + viewHeight: $viewHeight ) VStack(alignment: .leading, spacing: 0) { @@ -479,8 +507,10 @@ struct CourseDatesView_Previews: PreviewProvider { CourseDatesView( courseID: "", coordinate: .constant(0), - collapsed: .constant(false), - viewModel: viewModel) + collapsed: .constant(false), + viewHeight: .constant(0), + viewModel: viewModel + ) } } #endif diff --git a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift index c4bae6933..be2f67b26 100644 --- a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift +++ b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift @@ -91,17 +91,13 @@ public class CourseDatesViewModel: ObservableObject { await getCourseStructure(courseID: courseID) if courseDates?.courseDateBlocks == nil { isShowProgress = false - errorMessage = CoreLocalization.Error.unknownError + courseDates = nil return } isShowProgress = false - } catch let error { + } catch { isShowProgress = false - if error.isInternetError || error is NoCachedDataError { - errorMessage = CoreLocalization.Error.slowOrNoInternetConnection - } else { - errorMessage = CoreLocalization.Error.unknownError - } + courseDates = nil } } diff --git a/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift b/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift index 115262c09..c4c5923c7 100644 --- a/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsUpdatesDetailView.swift @@ -20,25 +20,26 @@ public struct HandoutsUpdatesDetailView: View { private var handouts: String? private var announcements: [CourseUpdate]? private let title: String + private let type: HandoutsItemType public init( handouts: String?, announcements: [CourseUpdate]?, router: CourseRouter, - cssInjector: CSSInjector + cssInjector: CSSInjector, + type: HandoutsItemType ) { - let noHandouts = handouts == nil && announcements == nil - - if announcements == nil { + switch type { + case .handouts: self.title = CourseLocalization.HandoutsCellHandouts.title - } else { + case .announcements: self.title = CourseLocalization.HandoutsCellAnnouncements.title } - - self.handouts = noHandouts ? CourseLocalization.Error.noHandouts : handouts + self.handouts = handouts self.announcements = announcements self.router = router self.cssInjector = cssInjector + self.type = type } private func updateColorScheme() { @@ -78,15 +79,31 @@ public struct HandoutsUpdatesDetailView: View { ZStack(alignment: .top) { Theme.Colors.background .ignoresSafeArea() - // MARK: - Page Body - WebViewHtml(html(), injections: [.accessibility, .readability]) - .padding(.top, 8) - .frame( - maxHeight: .infinity, - alignment: .topLeading) - .onRightSwipeGesture { - router.back() + + switch type { + case .handouts: + if handouts?.isEmpty ?? true { + FullScreenErrorView( + type: .noContent( + CourseLocalization.Error.handoutsUnavailable, + image: CoreAssets.noHandouts.swiftUIImage + ) + ) + } else { + webViewHtml } + case .announcements: + if announcements?.isEmpty ?? true { + FullScreenErrorView( + type: .noContent( + CourseLocalization.Error.announcementsUnavailable, + image: CoreAssets.noAnnouncements.swiftUIImage + ) + ) + } else { + webViewHtml + } + } } .navigationBarHidden(false) .navigationBarBackButtonHidden(false) @@ -97,6 +114,19 @@ public struct HandoutsUpdatesDetailView: View { } } + private var webViewHtml: some View { + // MARK: - Page Body + WebViewHtml(html(), injections: [.accessibility, .readability]) + .padding(.top, 8) + .frame( + maxHeight: .infinity, + alignment: .topLeading + ) + .onRightSwipeGesture { + router.back() + } + } + func html() -> String { var html: String = "" if let handouts { @@ -229,7 +259,8 @@ Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor i content: loremIpsumHtml, status: "nice")], router: CourseRouterMock(), - cssInjector: CSSInjectorMock() + cssInjector: CSSInjectorMock(), + type: .handouts ) } } diff --git a/Course/Course/Presentation/Handouts/HandoutsView.swift b/Course/Course/Presentation/Handouts/HandoutsView.swift index f08075fb5..e3998963c 100644 --- a/Course/Course/Presentation/Handouts/HandoutsView.swift +++ b/Course/Course/Presentation/Handouts/HandoutsView.swift @@ -14,6 +14,7 @@ struct HandoutsView: View { private let courseID: String @Binding private var coordinate: CGFloat @Binding private var collapsed: Bool + @Binding private var viewHeight: CGFloat @StateObject private var viewModel: HandoutsViewModel @@ -22,11 +23,13 @@ struct HandoutsView: View { courseID: String, coordinate: Binding, collapsed: Binding, + viewHeight: Binding, viewModel: HandoutsViewModel ) { self.courseID = courseID self._coordinate = coordinate self._collapsed = collapsed + self._viewHeight = viewHeight self._viewModel = StateObject(wrappedValue: { viewModel }()) } @@ -38,7 +41,8 @@ struct HandoutsView: View { ScrollView { DynamicOffsetView( coordinate: $coordinate, - collapsed: $collapsed + collapsed: $collapsed, + viewHeight: $viewHeight ) if viewModel.isShowProgress { HStack(alignment: .center) { @@ -48,12 +52,14 @@ struct HandoutsView: View { } } else { VStack(alignment: .leading) { - HandoutsItemCell(type: .handouts, onTapAction: { + HandoutsItemCell(type: .handouts, onTapAction: { type in viewModel.router.showHandoutsUpdatesView( handouts: viewModel.handouts, announcements: nil, router: viewModel.router, - cssInjector: viewModel.cssInjector) + cssInjector: viewModel.cssInjector, + type: type + ) viewModel.analytics.trackCourseScreenEvent( .courseHandouts, biValue: .courseHandouts, @@ -64,19 +70,19 @@ struct HandoutsView: View { .frame(height: 1) .overlay(Theme.Colors.cardViewStroke) .accessibilityIdentifier("divider") - HandoutsItemCell(type: .announcements, onTapAction: { - if !viewModel.updates.isEmpty { - viewModel.router.showHandoutsUpdatesView( - handouts: nil, - announcements: viewModel.updates, - router: viewModel.router, - cssInjector: viewModel.cssInjector) - viewModel.analytics.trackCourseScreenEvent( - .courseAnnouncement, - biValue: .courseAnnouncement, - courseID: courseID - ) - } + HandoutsItemCell(type: .announcements, onTapAction: { type in + viewModel.router.showHandoutsUpdatesView( + handouts: nil, + announcements: viewModel.updates, + router: viewModel.router, + cssInjector: viewModel.cssInjector, + type: type + ) + viewModel.analytics.trackCourseEvent( + .courseAnnouncement, + biValue: .courseAnnouncement, + courseID: courseID + ) }) }.padding(.horizontal, 32) Spacer(minLength: 84) @@ -94,22 +100,6 @@ struct HandoutsView: View { } } ) - - // 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 { @@ -140,57 +130,57 @@ struct HandoutsView_Previews: PreviewProvider { courseID: "", coordinate: .constant(0), collapsed: .constant(false), + viewHeight: .constant(0), viewModel: viewModel ) } } #endif -struct HandoutsItemCell: View { +public enum HandoutsItemType: String { + case handouts + case announcements - enum ItemType { - case handouts - case announcements - - var title: String { - switch self { - case .handouts: - return CourseLocalization.HandoutsCellHandouts.title - case .announcements: - return CourseLocalization.HandoutsCellAnnouncements.title - } + var title: String { + switch self { + case .handouts: + return CourseLocalization.HandoutsCellHandouts.title + case .announcements: + return CourseLocalization.HandoutsCellAnnouncements.title } - - var description: String { - switch self { - case .handouts: - return CourseLocalization.HandoutsCellHandouts.description - case .announcements: - return CourseLocalization.HandoutsCellAnnouncements.description - } - } - - var image: Image { - switch self { - case .handouts: - return CoreAssets.handouts.swiftUIImage - case .announcements: - return CoreAssets.announcements.swiftUIImage - } + } + + var description: String { + switch self { + case .handouts: + return CourseLocalization.HandoutsCellHandouts.description + case .announcements: + return CourseLocalization.HandoutsCellAnnouncements.description } } - private let type: ItemType - private let onTapAction: () -> Void + var image: Image { + switch self { + case .handouts: + return CoreAssets.handouts.swiftUIImage + case .announcements: + return CoreAssets.announcements.swiftUIImage + } + } +} + +struct HandoutsItemCell: View { + private let type: HandoutsItemType + private let onTapAction: (HandoutsItemType) -> Void - public init(type: ItemType, onTapAction: @escaping () -> Void) { + public init(type: HandoutsItemType, onTapAction: @escaping (HandoutsItemType) -> Void) { self.type = type self.onTapAction = onTapAction } public var body: some View { Button(action: { - onTapAction() + onTapAction(type) }, label: { HStack(spacing: 12) { type.image.renderingMode(.template) diff --git a/Course/Course/Presentation/Handouts/HandoutsViewModel.swift b/Course/Course/Presentation/Handouts/HandoutsViewModel.swift index c5fc64a64..f4380dc6c 100644 --- a/Course/Course/Presentation/Handouts/HandoutsViewModel.swift +++ b/Course/Course/Presentation/Handouts/HandoutsViewModel.swift @@ -55,11 +55,6 @@ public class HandoutsViewModel: ObservableObject { } } catch let error { isShowProgress = false - if error.isInternetError || error is NoCachedDataError { - errorMessage = CoreLocalization.Error.slowOrNoInternetConnection - } else { - errorMessage = CoreLocalization.Error.unknownError - } } } @@ -71,12 +66,6 @@ public class HandoutsViewModel: ObservableObject { isShowProgress = false } catch let error { isShowProgress = false - if error.isInternetError || error is NoCachedDataError { - errorMessage = CoreLocalization.Error.slowOrNoInternetConnection - } else { - errorMessage = CoreLocalization.Error.unknownError - } } } - } diff --git a/Course/Course/Presentation/Offline/OfflineView.swift b/Course/Course/Presentation/Offline/OfflineView.swift index 02cf0ee3c..6129b251b 100644 --- a/Course/Course/Presentation/Offline/OfflineView.swift +++ b/Course/Course/Presentation/Offline/OfflineView.swift @@ -55,6 +55,7 @@ struct OfflineView: View { private let courseID: String @Binding private var coordinate: CGFloat @Binding private var collapsed: Bool + @Binding private var viewHeight: CGFloat @StateObject private var viewModel: CourseContainerViewModel @@ -63,11 +64,13 @@ struct OfflineView: View { courseID: String, coordinate: Binding, collapsed: Binding, + viewHeight: Binding, viewModel: CourseContainerViewModel ) { self.courseID = courseID self._coordinate = coordinate self._collapsed = collapsed + self._viewHeight = viewHeight self._viewModel = StateObject(wrappedValue: { viewModel }()) } @@ -88,7 +91,8 @@ struct OfflineView: View { VStack(alignment: .leading) { DynamicOffsetView( coordinate: $coordinate, - collapsed: $collapsed + collapsed: $collapsed, + viewHeight: $viewHeight ) TotalDownloadedProgressView( downloadedFilesSize: viewModel.downloadedFilesSize, @@ -260,6 +264,7 @@ struct OfflineView: View { courseID: "123", coordinate: .constant(0), collapsed: .constant(false), + viewHeight: .constant(0), viewModel: vm ).onAppear { vm.isShowProgress = false diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index 4464f9fe2..a5dc918ae 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -29,6 +29,7 @@ public struct CourseOutlineView: View { @Binding private var selection: Int @Binding private var coordinate: CGFloat @Binding private var collapsed: Bool + @Binding private var viewHeight: CGFloat @State private var expandedChapters: [String: Bool] = [:] @@ -40,6 +41,7 @@ public struct CourseOutlineView: View { selection: Binding, coordinate: Binding, collapsed: Binding, + viewHeight: Binding, dateTabIndex: Int ) { self.title = title @@ -49,6 +51,7 @@ public struct CourseOutlineView: View { self._selection = selection self._coordinate = coordinate self._collapsed = collapsed + self._viewHeight = viewHeight self.dateTabIndex = dateTabIndex } @@ -59,55 +62,75 @@ public struct CourseOutlineView: View { ScrollView { DynamicOffsetView( coordinate: $coordinate, - collapsed: $collapsed + collapsed: $collapsed, + viewHeight: $viewHeight ) RefreshProgressView(isShowRefresh: $viewModel.isShowRefresh) VStack(alignment: .leading) { - downloadQualityBars + if isVideo, + viewModel.isShowProgress == false { + downloadQualityBars(proxy: proxy) + } certificateView - if let continueWith = viewModel.continueWith, - let courseStructure = viewModel.courseStructure, + if viewModel.courseStructure == nil, + viewModel.isShowProgress == false, !isVideo { - let chapter = courseStructure.childs[continueWith.chapterIndex] - let sequential = chapter.childs[continueWith.sequentialIndex] - let continueUnit = sequential.childs[continueWith.verticalIndex] - - ContinueWithView( - data: continueWith, - courseContinueUnit: continueUnit - ) { - viewModel.openLastVisitedBlock() - } - } - - if let course = isVideo - ? viewModel.courseVideosStructure - : viewModel.courseStructure { - - if !isVideo, let progress = course.courseProgress, progress.totalAssignmentsCount != 0 { - CourseProgressView(progress: progress) - .padding(.horizontal, 24) - .padding(.top, 16) - .padding(.bottom, 8) - } - - // MARK: - Sections - CustomDisclosureGroup( - isVideo: isVideo, - course: course, - proxy: proxy, - viewModel: viewModel + FullScreenErrorView( + type: .noContent( + CourseLocalization.Error.coursewareUnavailable, + image: CoreAssets.information.swiftUIImage + ) ) + .frame(maxWidth: .infinity) + .frame(height: proxy.size.height - viewHeight) } else { - if let courseStart = viewModel.courseStart { - Text(courseStart > Date() ? CourseLocalization.Outline.courseHasntStarted : "") - .frame(maxWidth: .infinity) - .padding(.top, 100) + if let continueWith = viewModel.continueWith, + let courseStructure = viewModel.courseStructure, + !isVideo { + let chapter = courseStructure.childs[continueWith.chapterIndex] + let sequential = chapter.childs[continueWith.sequentialIndex] + let continueUnit = sequential.childs[continueWith.verticalIndex] + + ContinueWithView( + data: continueWith, + courseContinueUnit: continueUnit + ) { + viewModel.openLastVisitedBlock() + } + } + + if let course = isVideo + ? viewModel.courseVideosStructure + : viewModel.courseStructure { + + if !isVideo, + let progress = course.courseProgress, + progress.totalAssignmentsCount != 0 { + CourseProgressView(progress: progress) + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 8) + } + + // MARK: - Sections + CustomDisclosureGroup( + isVideo: isVideo, + course: course, + proxy: proxy, + viewModel: viewModel + ) + } else { + if let courseStart = viewModel.courseStart { + Text(courseStart > Date() ? CourseLocalization.Outline.courseHasntStarted : "") + .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity) + .padding(.top, 100) + } } + Spacer(minLength: 200) } - Spacer(minLength: 200) } .frameLimit(width: proxy.size.width) } @@ -208,9 +231,8 @@ public struct CourseOutlineView: View { } @ViewBuilder - private var downloadQualityBars: some View { - if isVideo, - let courseVideosStructure = viewModel.courseVideosStructure, + private func downloadQualityBars(proxy: GeometryProxy) -> some View { + if let courseVideosStructure = viewModel.courseVideosStructure, viewModel.hasVideoForDowbloads() { VStack(spacing: 0) { CourseVideoDownloadBarView( @@ -236,6 +258,16 @@ public struct CourseOutlineView: View { } } } + } else { + FullScreenErrorView( + type: .noContent( + CourseLocalization.Error.videosUnavailable, + image: CoreAssets.noVideos.swiftUIImage + ) + ) + .frame(maxWidth: .infinity) + .frame(height: proxy.size.height - viewHeight) + Spacer(minLength: -200) } } @ViewBuilder @@ -327,6 +359,7 @@ struct CourseOutlineView_Previews: PreviewProvider { selection: $selection, coordinate: .constant(0), collapsed: .constant(false), + viewHeight: .constant(0), dateTabIndex: 2 ) .preferredColorScheme(.light) @@ -340,6 +373,7 @@ struct CourseOutlineView_Previews: PreviewProvider { selection: $selection, coordinate: .constant(0), collapsed: .constant(false), + viewHeight: .constant(0), dateTabIndex: 2 ) .preferredColorScheme(.dark) diff --git a/Course/Course/SwiftGen/Strings.swift b/Course/Course/SwiftGen/Strings.swift index 6b24786a9..84693e4a3 100644 --- a/Course/Course/SwiftGen/Strings.swift +++ b/Course/Course/SwiftGen/Strings.swift @@ -301,14 +301,22 @@ public enum CourseLocalization { public static let videos = CourseLocalization.tr("Localizable", "DOWNLOAD.VIDEOS", fallback: "Videos") } public enum Error { + /// There are currently no announcements for this course. + public static let announcementsUnavailable = CourseLocalization.tr("Localizable", "ERROR.ANNOUNCEMENTS_UNAVAILABLE", fallback: "There are currently no announcements for this course.") /// 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") + /// Course dates are not currently available. + public static let courseDateUnavailable = CourseLocalization.tr("Localizable", "ERROR.COURSE_DATE_UNAVAILABLE", fallback: "Course dates are not currently available.") + /// No course content is currently available. + public static let coursewareUnavailable = CourseLocalization.tr("Localizable", "ERROR.COURSEWARE_UNAVAILABLE", fallback: "No course content is currently available.") + /// There are currently no handouts for this course. + public static let handoutsUnavailable = CourseLocalization.tr("Localizable", "ERROR.HANDOUTS_UNAVAILABLE", 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 public static let reload = CourseLocalization.tr("Localizable", "ERROR.RELOAD", fallback: "Reload") + /// There are currently no vidoes for this course. + public static let videosUnavailable = CourseLocalization.tr("Localizable", "ERROR.VIDEOS_UNAVAILABLE", fallback: "There are currently no vidoes for this course.") } public enum HandoutsCellAnnouncements { /// Keep up with the latest news diff --git a/Course/Course/en.lproj/Localizable.strings b/Course/Course/en.lproj/Localizable.strings index 2cd63e571..3fe024cb8 100644 --- a/Course/Course/en.lproj/Localizable.strings +++ b/Course/Course/en.lproj/Localizable.strings @@ -27,7 +27,11 @@ "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"; +"ERROR.HANDOUTS_UNAVAILABLE" = "There are currently no handouts for this course."; +"ERROR.ANNOUNCEMENTS_UNAVAILABLE" = "There are currently no announcements for this course."; +"ERROR.VIDEOS_UNAVAILABLE" = "There are currently no vidoes for this course."; +"ERROR.COURSE_DATE_UNAVAILABLE" = "Course dates are not currently available."; +"ERROR.COURSEWARE_UNAVAILABLE" = "No course content is currently available."; "ALERT.ROTATE_DEVICE" = "Rotate your device to view this video in full screen."; "ALERT.ACCEPT" = "Accept"; diff --git a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift index 46511fb7f..09895748c 100644 --- a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift @@ -229,9 +229,8 @@ final class CourseContainerViewModelTests: XCTestCase { Verify(interactor, .getCourseBlocks(courseID: .any)) XCTAssertFalse(viewModel.isShowProgress) - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) XCTAssertNil(viewModel.courseStructure) + XCTAssertNil(viewModel.courseVideosStructure) } func testGetCourseBlocksNoCacheError() async throws { @@ -270,9 +269,8 @@ final class CourseContainerViewModelTests: XCTestCase { Verify(interactor, .getCourseBlocks(courseID: .any)) XCTAssertFalse(viewModel.isShowProgress) - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) XCTAssertNil(viewModel.courseStructure) + XCTAssertNil(viewModel.courseVideosStructure) } func testGetCourseBlocksUnknownError() async throws { @@ -311,9 +309,8 @@ final class CourseContainerViewModelTests: XCTestCase { Verify(interactor, .getCourseBlocks(courseID: .any)) XCTAssertFalse(viewModel.isShowProgress) - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) XCTAssertNil(viewModel.courseStructure) + XCTAssertNil(viewModel.courseVideosStructure) } func testTabSelectedAnalytics() { @@ -485,7 +482,7 @@ final class CourseContainerViewModelTests: XCTestCase { coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure - await viewModel.setDownloadsStates() + await viewModel.setDownloadsStates(courseStructure: courseStructure) await viewModel.download( state: .available, @@ -615,7 +612,7 @@ final class CourseContainerViewModelTests: XCTestCase { coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure - await viewModel.setDownloadsStates() + await viewModel.setDownloadsStates(courseStructure: courseStructure) await viewModel.download( state: .available, @@ -744,7 +741,7 @@ final class CourseContainerViewModelTests: XCTestCase { coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure - await viewModel.setDownloadsStates() + await viewModel.setDownloadsStates(courseStructure: courseStructure) await viewModel.download( state: .available, @@ -874,7 +871,7 @@ final class CourseContainerViewModelTests: XCTestCase { coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure - await viewModel.setDownloadsStates() + await viewModel.setDownloadsStates(courseStructure: courseStructure) let exp = expectation(description: "Task Starting") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -1013,7 +1010,7 @@ final class CourseContainerViewModelTests: XCTestCase { coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure - await viewModel.setDownloadsStates() + await viewModel.setDownloadsStates(courseStructure: courseStructure) let exp = expectation(description: "Task Starting") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -1152,7 +1149,7 @@ final class CourseContainerViewModelTests: XCTestCase { coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure - await viewModel.setDownloadsStates() + await viewModel.setDownloadsStates(courseStructure: courseStructure) let exp = expectation(description: "Task Starting") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { @@ -1313,7 +1310,7 @@ final class CourseContainerViewModelTests: XCTestCase { coreAnalytics: CoreAnalyticsMock() ) viewModel.courseStructure = courseStructure - await viewModel.setDownloadsStates() + await viewModel.setDownloadsStates(courseStructure: courseStructure) let exp = expectation(description: "Task Starting") DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { diff --git a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift index 841c56bbc..34cde8e6e 100644 --- a/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseDateViewModelTests.swift @@ -100,8 +100,8 @@ final class CourseDateViewModelTests: XCTestCase { Verify(interactor, .getCourseDates(courseID: .any)) - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError, "Error view should be shown on unknown error.") + XCTAssertNil(viewModel.courseDates) + XCTAssertFalse(viewModel.isShowProgress) } func testNoInternetConnectionError() async throws { @@ -131,8 +131,8 @@ final class CourseDateViewModelTests: XCTestCase { Verify(interactor, .getCourseDates(courseID: .any)) - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection, "Error message should be set to 'slow or no internet connection'.") + XCTAssertNil(viewModel.courseDates) + XCTAssertFalse(viewModel.isShowProgress) } func testSortedDateTodayToCourseDateBlockDict() { diff --git a/Course/CourseTests/Presentation/Unit/HandoutsViewModelTests.swift b/Course/CourseTests/Presentation/Unit/HandoutsViewModelTests.swift index c5874f90c..bad7ec2f6 100644 --- a/Course/CourseTests/Presentation/Unit/HandoutsViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/HandoutsViewModelTests.swift @@ -66,8 +66,6 @@ final class HandoutsViewModelTests: XCTestCase { XCTAssert(viewModel.handouts == nil) XCTAssertFalse(viewModel.isShowProgress) - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) } func testGetHandoutsUnknownError() async throws { @@ -92,8 +90,6 @@ final class HandoutsViewModelTests: XCTestCase { XCTAssert(viewModel.handouts == nil) XCTAssertFalse(viewModel.isShowProgress) - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) } func testGetUpdatesSuccess() async throws { @@ -146,8 +142,6 @@ final class HandoutsViewModelTests: XCTestCase { XCTAssertTrue(viewModel.updates.isEmpty) XCTAssertFalse(viewModel.isShowProgress) - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) } func testGetUpdatesUnknownError() async throws { @@ -172,8 +166,6 @@ final class HandoutsViewModelTests: XCTestCase { XCTAssertTrue(viewModel.updates.isEmpty) XCTAssertFalse(viewModel.isShowProgress) - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) } } diff --git a/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift b/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift index 83680dc7c..8e96c9d77 100644 --- a/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift +++ b/Dashboard/Dashboard/Presentation/Elements/PrimaryCardView.swift @@ -27,6 +27,7 @@ public struct PrimaryCardView: View { private var assignmentAction: (String?) -> Void private var openCourseAction: () -> Void private var resumeAction: () -> Void + @Environment(\.isHorizontal) var isHorizontal public init( courseName: String, @@ -64,22 +65,65 @@ public struct PrimaryCardView: View { public var body: some View { ZStack { + if isHorizontal { + horizontalLayout + } else { + verticalLayout + } + } + .background(Theme.Colors.courseCardBackground) + .cornerRadius(8) + .shadow(color: Theme.Colors.courseCardShadow, radius: 4, x: 0, y: 3) + .padding(20) + } + + @ViewBuilder + var verticalLayout: some View { + VStack(alignment: .leading, spacing: 0) { + Group { + courseBanner + .frame(height: 140) + .clipped() + ProgressLineView(progressEarned: progressEarned, progressPossible: progressPossible) + courseTitle + } + .onTapGesture { + openCourseAction() + } + assignments + } + } + + @ViewBuilder + var horizontalLayout: some View { + HStack(alignment: .top, spacing: 0) { VStack(alignment: .leading, spacing: 0) { - Group { + GeometryReader { proxy in courseBanner - ProgressLineView(progressEarned: progressEarned, progressPossible: progressPossible) + .frame(width: proxy.size.width) + .clipped() + } + ProgressLineView(progressEarned: progressEarned, progressPossible: progressPossible) + } + .onTapGesture { + openCourseAction() + } + VStack(alignment: .leading, spacing: 0) { + ZStack(alignment: .leading) { courseTitle } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading) + .background( + Theme.Colors.background // need for tap area + ) + .onTapGesture { openCourseAction() } assignments } } - .background(Theme.Colors.courseCardBackground) - .cornerRadius(8) - .shadow(color: Theme.Colors.courseCardShadow, radius: 4, x: 0, y: 3) - .padding(20) + .frame(minHeight: 240) } private var assignments: some View { @@ -223,8 +267,6 @@ public struct PrimaryCardView: View { .onFailureImage(CoreAssets.noCourseImage.image) .resizable() .aspectRatio(contentMode: .fill) - .frame(height: 140) - .clipped() .accessibilityElement(children: .ignore) .accessibilityIdentifier("course_image") } diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift index 77e23d8e1..ee44d90d1 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift @@ -17,12 +17,14 @@ public struct DiscussionTopicsView: View { private let courseID: String @Binding private var coordinate: CGFloat @Binding private var collapsed: Bool + @Binding private var viewHeight: CGFloat @State private var runOnce: Bool = false public init( courseID: String, coordinate: Binding, collapsed: Binding, + viewHeight: Binding, viewModel: DiscussionTopicsViewModel, router: DiscussionRouter ) { @@ -30,6 +32,7 @@ public struct DiscussionTopicsView: View { self.courseID = courseID self._coordinate = coordinate self._collapsed = collapsed + self._viewHeight = viewHeight self.router = router } @@ -40,7 +43,8 @@ public struct DiscussionTopicsView: View { ScrollView { DynamicOffsetView( coordinate: $coordinate, - collapsed: $collapsed + collapsed: $collapsed, + viewHeight: $viewHeight ) RefreshProgressView(isShowRefresh: $viewModel.isShowRefresh) // MARK: - Search fake field @@ -48,37 +52,39 @@ public struct DiscussionTopicsView: View { bannerDiscussionsDisabled } - HStack(spacing: 11) { - Image(systemName: "magnifyingglass") - .foregroundColor(Theme.Colors.textInputTextColor) - .padding(.leading, 16) - .padding(.top, 1) - Text(DiscussionLocalization.Topics.search) - .foregroundColor(Theme.Colors.textInputTextColor) - .font(Theme.Fonts.bodyMedium) - Spacer() - } - .frame(minHeight: 48) - .background( - Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputBackground) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill(Theme.Colors.textInputUnfocusedStroke) - ) - .onTapGesture { - viewModel.router.showDiscussionsSearch( - courseID: courseID, - isBlackedOut: viewModel.isBlackedOut + if let topics = viewModel.discussionTopics, topics.count > 0 { + HStack(spacing: 11) { + Image(systemName: "magnifyingglass") + .foregroundColor(Theme.Colors.textInputTextColor) + .padding(.leading, 16) + .padding(.top, 1) + Text(DiscussionLocalization.Topics.search) + .foregroundColor(Theme.Colors.textInputTextColor) + .font(Theme.Fonts.bodyMedium) + Spacer() + } + .frame(minHeight: 48) + .background( + Theme.Shapes.textInputShape + .fill(Theme.Colors.textInputBackground) + ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(Theme.Colors.textInputUnfocusedStroke) ) + .onTapGesture { + viewModel.router.showDiscussionsSearch( + courseID: courseID, + isBlackedOut: viewModel.isBlackedOut + ) + } + .frameLimit(width: proxy.size.width) + .padding(.horizontal, 24) + .padding(.top, 10) + .accessibilityElement(children: .ignore) + .accessibilityLabel(DiscussionLocalization.Topics.search) } - .frameLimit(width: proxy.size.width) - .padding(.horizontal, 24) - .padding(.top, 10) - .accessibilityElement(children: .ignore) - .accessibilityLabel(DiscussionLocalization.Topics.search) // MARK: - Page Body VStack { @@ -154,7 +160,16 @@ public struct DiscussionTopicsView: View { } } } - + } else if viewModel.isShowProgress == false { + FullScreenErrorView( + type: .noContent( + DiscussionLocalization.Error.unableToLoadDiscussion, + image: CoreAssets.information.swiftUIImage + ) + ) + .frame(maxWidth: .infinity) + .frame(height: proxy.size.height - viewHeight) + Spacer(minLength: -200) } Spacer(minLength: 200) } @@ -225,6 +240,7 @@ struct DiscussionView_Previews: PreviewProvider { courseID: "", coordinate: .constant(0), collapsed: .constant(false), + viewHeight: .constant(0), viewModel: vm, router: router ) @@ -236,6 +252,7 @@ struct DiscussionView_Previews: PreviewProvider { courseID: "", coordinate: .constant(0), collapsed: .constant(false), + viewHeight: .constant(0), viewModel: vm, router: router ) diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift index 51fde8edd..0984ebb6b 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsViewModel.swift @@ -182,14 +182,10 @@ public class DiscussionTopicsViewModel: ObservableObject { discussionTopics = generateTopics(topics: topics) isShowProgress = false isShowRefresh = false - } catch let error { + } catch { isShowProgress = false isShowRefresh = false - if error.isInternetError { - errorMessage = CoreLocalization.Error.slowOrNoInternetConnection - } else { - errorMessage = CoreLocalization.Error.unknownError - } + discussionTopics = nil } } } diff --git a/Discussion/Discussion/SwiftGen/Strings.swift b/Discussion/Discussion/SwiftGen/Strings.swift index 487a95f22..3346df120 100644 --- a/Discussion/Discussion/SwiftGen/Strings.swift +++ b/Discussion/Discussion/SwiftGen/Strings.swift @@ -71,6 +71,11 @@ public enum DiscussionLocalization { /// Topic public static let topic = DiscussionLocalization.tr("Localizable", "CREATE_THREAD.TOPIC", fallback: "Topic") } + public enum Error { + /// Unable to load discussions. + /// Try again later. + public static let unableToLoadDiscussion = DiscussionLocalization.tr("Localizable", "ERROR.UNABLE_TO_LOAD_DISCUSSION", fallback: "Unable to load discussions.\nTry again later.") + } public enum Post { /// Last post: public static let lastPost = DiscussionLocalization.tr("Localizable", "POST.LAST_POST", fallback: "Last post:") diff --git a/Discussion/Discussion/en.lproj/Localizable.strings b/Discussion/Discussion/en.lproj/Localizable.strings index 55819f2ae..2c4165378 100644 --- a/Discussion/Discussion/en.lproj/Localizable.strings +++ b/Discussion/Discussion/en.lproj/Localizable.strings @@ -61,3 +61,5 @@ "SEARCH.EMPTY_DESCRIPTION" = "Start typing to find the topics"; "ANONYMOUS" = "Anonymous"; + +"ERROR.UNABLE_TO_LOAD_DISCUSSION" = "Unable to load discussions.\nTry again later."; diff --git a/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift b/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift index 32f957a85..eea0bb06f 100644 --- a/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionTopicsViewModelTests.swift @@ -87,9 +87,8 @@ final class DiscussionTopicsViewModelTests: XCTestCase { XCTAssertNil(viewModel.topics) XCTAssertNil(viewModel.discussionTopics) - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.slowOrNoInternetConnection) XCTAssertFalse(viewModel.isShowProgress) + XCTAssertFalse(viewModel.isShowRefresh) } func testGetTopicsUnknownError() async throws { @@ -113,8 +112,7 @@ final class DiscussionTopicsViewModelTests: XCTestCase { XCTAssertNil(viewModel.topics) XCTAssertNil(viewModel.discussionTopics) - XCTAssertTrue(viewModel.showError) - XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) XCTAssertFalse(viewModel.isShowProgress) + XCTAssertFalse(viewModel.isShowRefresh) } } diff --git a/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift b/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift index 15f026235..259cc2171 100644 --- a/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift +++ b/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift @@ -171,7 +171,8 @@ extension Router: DeepLinkRouter { handouts: nil, announcements: updates, router: self, - cssInjector: cssInjector + cssInjector: cssInjector, + type: .announcements ) } @@ -187,7 +188,8 @@ extension Router: DeepLinkRouter { handouts: handouts, announcements: nil, router: self, - cssInjector: cssInjector + cssInjector: cssInjector, + type: .handouts ) } diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index d06b65e9a..b90f405d9 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -435,13 +435,15 @@ public class Router: AuthorizationRouter, handouts: String?, announcements: [CourseUpdate]?, router: Course.CourseRouter, - cssInjector: CSSInjector + cssInjector: CSSInjector, + type: HandoutsItemType ) { let view = HandoutsUpdatesDetailView( handouts: handouts, announcements: announcements, router: router, - cssInjector: cssInjector + cssInjector: cssInjector, + type: type ) let controller = UIHostingController(rootView: view) navigationController.pushViewController(controller, animated: true) From f5e998946d4c23903e769e03625535952722abab Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Wed, 9 Oct 2024 18:57:54 +0300 Subject: [PATCH 44/55] Fix: offline file deletion issue (#527) * fix: update DownloadManager.swift * fix: fix the temp data deletion issue * fix: upgrade fastlane --------- Co-authored-by: Volodymyr Chekyrta --- Core/Core/Network/DownloadManager.swift | 81 ++++++++++++++++--------- Gemfile.lock | 4 +- 2 files changed, 56 insertions(+), 29 deletions(-) diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index 6b6b30f19..dad856a1e 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -297,7 +297,7 @@ public class DownloadManager: DownloadManagerProtocol { public func deleteFile(blocks: [CourseBlock]) async { for block in blocks { do { - if let fileURL = fileUrl(for: block.id), + if let fileURL = fileOrFolderUrl(for: block.id), FileManager.default.fileExists(atPath: fileURL.path) { try FileManager.default.removeItem(at: fileURL) } @@ -367,7 +367,7 @@ public class DownloadManager: DownloadManagerProtocol { public func deleteAllFiles() async { let downloadsData = await getDownloadTasks() for downloadData in downloadsData { - if let fileURL = fileUrl(for: downloadData.id) { + if let fileURL = fileOrFolderUrl(for: downloadData.id) { do { try FileManager.default.removeItem(at: fileURL) } catch { @@ -395,6 +395,24 @@ public class DownloadManager: DownloadManagerProtocol { return path?.appendingPathComponent(data.fileName) } } + + public func fileOrFolderUrl(for blockId: String) -> URL? { + guard let data = persistence.downloadDataTask(for: blockId), + data.url.count > 0, + data.state == .finished else { return nil } + let path = filesFolderUrl + switch data.type { + case .html, .problem: + if let folderUrl = URL(string: data.url) { + let folder = folderUrl.deletingPathExtension().lastPathComponent + return path?.appendingPathComponent(folder) + } else { + return nil + } + case .video: + return path?.appendingPathComponent(data.fileName) + } + } // MARK: - Private Intents @@ -453,10 +471,12 @@ public class DownloadManager: DownloadManagerProtocol { resumeData: download.resumeData ) self.isDownloadingInProgress = true + let destination: DownloadRequest.Destination = { _, _ in let file = folderURL.appendingPathComponent(download.fileName) return (file, [.createIntermediateDirectories, .removePreviousFile]) } + if let resumeData = download.resumeData { downloadRequest = AF.download(resumingWith: resumeData, to: destination) } else { @@ -464,41 +484,43 @@ public class DownloadManager: DownloadManagerProtocol { } downloadRequest?.downloadProgress { [weak self] prog in - guard let self else { return } + guard let self = self else { return } let fractionCompleted = prog.fractionCompleted self.currentDownloadTask?.progress = fractionCompleted self.currentDownloadTask?.state = .inProgress self.currentDownloadEventPublisher.send(.progress(fractionCompleted, download)) let completed = Double(fractionCompleted * 100) - debugLog(">>>>> Downloading", download.url, completed, "%") + debugLog(">>>>> Downloading File", download.url, completed, "%") } - downloadRequest?.responseData { [weak self] data in - guard let self else { return } - if let error = data.error { + downloadRequest?.responseURL { [weak self] response in + guard let self = self else { return } + if let error = response.error { if error.asAFError?.isExplicitlyCancelledError == false { - failedDownloads.append(download) + self.failedDownloads.append(download) Task { try? await self.newDownload() } return } } - self.persistence.updateDownloadState( - id: download.id, - state: .finished, - resumeData: nil - ) - self.currentDownloadTask?.state = .finished - self.currentDownloadEventPublisher.send(.finished(download)) - Task { - try? await self.newDownload() + if response.fileURL != nil { + self.persistence.updateDownloadState( + id: download.id, + state: .finished, + resumeData: nil + ) + self.currentDownloadTask?.state = .finished + self.currentDownloadEventPublisher.send(.finished(download)) + Task { + try? await self.newDownload() + } } } } private func downloadHTMLWithProgress(_ download: DownloadDataTask) throws { - guard let url = URL(string: download.url) else { + guard let url = URL(string: download.url), let folderURL = self.filesFolderUrl else { return } @@ -508,10 +530,17 @@ public class DownloadManager: DownloadManagerProtocol { resumeData: download.resumeData ) self.isDownloadingInProgress = true + + let destination: DownloadRequest.Destination = { _, _ in + let fileName = URL(string: download.url)?.lastPathComponent ?? "file.zip" + let file = folderURL.appendingPathComponent(fileName) + return (file, [.createIntermediateDirectories, .removePreviousFile]) + } + if let resumeData = download.resumeData { - downloadRequest = AF.download(resumingWith: resumeData) + downloadRequest = AF.download(resumingWith: resumeData, to: destination) } else { - downloadRequest = AF.download(url) + downloadRequest = AF.download(url, to: destination) } downloadRequest?.downloadProgress { [weak self] prog in @@ -521,12 +550,12 @@ public class DownloadManager: DownloadManagerProtocol { self.currentDownloadTask?.state = .inProgress self.currentDownloadEventPublisher.send(.progress(fractionCompleted, download)) let completed = Double(fractionCompleted * 100) - debugLog(">>>>> Downloading", download.url, completed, "%") + debugLog(">>>>> Downloading HTML", download.url, completed, "%") } - downloadRequest?.responseData { [weak self] data in + downloadRequest?.responseURL { [weak self] response in guard let self else { return } - if let error = data.error { + if let error = response.error { if error.asAFError?.isExplicitlyCancelledError == false { failedDownloads.append(download) Task { @@ -535,10 +564,8 @@ public class DownloadManager: DownloadManagerProtocol { return } } - if let data = data.value, let url = self.filesFolderUrl, - let fileName = URL(string: download.url)?.lastPathComponent { - self.saveFile(fileName: fileName, data: data, folderURL: url) - self.unzipFile(url: url.appendingPathComponent(fileName)) + if let fileURL = response.fileURL { + self.unzipFile(url: fileURL) self.persistence.updateDownloadState( id: download.id, state: .finished, diff --git a/Gemfile.lock b/Gemfile.lock index af3a47938..f6449ff37 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,7 +10,7 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.3.0) - aws-partitions (1.983.0) + aws-partitions (1.987.0) aws-sdk-core (3.209.1) aws-eventstream (~> 1, >= 1.3.0) aws-partitions (~> 1, >= 1.651.0) @@ -68,7 +68,7 @@ GEM faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.3.1) - fastlane (2.223.1) + fastlane (2.224.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) From ca2a32dbc4231f96904a1939bf855a09fe02718c Mon Sep 17 00:00:00 2001 From: RawanMatar89 <41669180+RawanMatar89@users.noreply.github.com> Date: Thu, 24 Oct 2024 22:54:19 +0300 Subject: [PATCH 45/55] feat: add single sign on feature using SAML (#447) * feat: add single sign on by saml * fix: saml sso fix unit tests * fix: saml sso configuration and UI * fix: saml sso review changes * fix: update default config files for saml sso review changes * fix: update code depending on saml sso review changes * fix: updating sign in sso login title in auth Localizable.strings * fix: remove comma from yaml files * fix: reset default configuration, hide sso login button by default --- .../Authorization.xcodeproj/project.pbxproj | 40 ++- .../Presentation/AuthorizationAnalytics.swift | 3 + .../Presentation/Login/SignInView.swift | 235 +++++++++++------- .../Presentation/Login/SignInViewModel.swift | 14 ++ .../Registration/SignUpView.swift | 4 +- .../Presentation/SSO/ContainerWebView.swift | 46 ++++ .../Presentation/SSO/SSOHelper.swift | 80 ++++++ .../Presentation/SSO/SSOWebView.swift | 91 +++++++ .../Presentation/SSO/SSOWebViewModel.swift | 119 +++++++++ .../Presentation/Startup/StartupView.swift | 2 + .../Authorization/SwiftGen/Strings.swift | 8 + .../en.lproj/Localizable.strings | 5 +- .../AuthorizationMock.generated.swift | 61 ++++- .../Login/SignInViewModelTests.swift | 28 ++- Core/Core.xcodeproj/project.pbxproj | 32 +-- Core/Core/Configuration/BaseRouter.swift | 4 + Core/Core/Configuration/Config/Config.swift | 30 +++ .../Config/UIComponentsConfig.swift | 9 + .../Core/Data/Repository/AuthRepository.swift | 15 ++ Core/Core/Domain/AuthInteractor.swift | 6 + Core/Core/SwiftGen/Strings.swift | 6 +- .../View/Base/LogistrationBottomView.swift | 21 +- Core/Core/en.lproj/Localizable.strings | 3 +- Course/Course.xcodeproj/project.pbxproj | 16 +- Course/CourseTests/CourseMock.generated.swift | 37 ++- Dashboard/Dashboard.xcodeproj/project.pbxproj | 16 +- .../DashboardMock.generated.swift | 29 +++ Discovery/Discovery.xcodeproj/project.pbxproj | 16 +- Discovery/Discovery/Info.plist | 7 - .../NativeDiscovery/CourseDetailsView.swift | 7 + .../NativeDiscovery/DiscoveryView.swift | 8 +- .../WebDiscovery/DiscoveryWebview.swift | 12 +- .../DiscoveryWebviewViewModel.swift | 17 +- .../DiscoveryMock.generated.swift | 41 ++- .../DiscussionMock.generated.swift | 38 +++ OpenEdX.xcodeproj/project.pbxproj | 18 +- OpenEdX/DI/AppAssembly.swift | 6 + OpenEdX/DI/ScreenAssembly.swift | 9 + OpenEdX/Info.plist | 6 - OpenEdX/Router.swift | 10 + Profile/Profile.xcodeproj/project.pbxproj | 16 +- .../ProfileTests/ProfileMock.generated.swift | 62 ++++- Theme/Theme.xcodeproj/project.pbxproj | 16 +- WhatsNew/WhatsNew.xcodeproj/project.pbxproj | 16 +- WhatsNew/WhatsNew/Info.plist | 7 - default_config/dev/config.yaml | 11 + default_config/prod/config.yaml | 10 + default_config/stage/config.yaml | 10 + 48 files changed, 1066 insertions(+), 237 deletions(-) create mode 100644 Authorization/Authorization/Presentation/SSO/ContainerWebView.swift create mode 100644 Authorization/Authorization/Presentation/SSO/SSOHelper.swift create mode 100644 Authorization/Authorization/Presentation/SSO/SSOWebView.swift create mode 100644 Authorization/Authorization/Presentation/SSO/SSOWebViewModel.swift diff --git a/Authorization/Authorization.xcodeproj/project.pbxproj b/Authorization/Authorization.xcodeproj/project.pbxproj index ffebcdf02..2a9a60060 100644 --- a/Authorization/Authorization.xcodeproj/project.pbxproj +++ b/Authorization/Authorization.xcodeproj/project.pbxproj @@ -26,6 +26,10 @@ 0770DE6B28D0C035006D8A5D /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0770DE6D28D0C035006D8A5D /* Localizable.strings */; }; 0770DE7128D0C0E7006D8A5D /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE7028D0C0E7006D8A5D /* Strings.swift */; }; 5FB79D2802949372CDAF08D6 /* Pods_App_Authorization_AuthorizationTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4FAE9B7FD61FF88C9C4FE1E8 /* Pods_App_Authorization_AuthorizationTests.framework */; }; + 99C1654B2C0C4F0600DC384D /* ContainerWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C1654A2C0C4F0600DC384D /* ContainerWebView.swift */; }; + 99C1654D2C0C4F2F00DC384D /* SSOHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C1654C2C0C4F2F00DC384D /* SSOHelper.swift */; }; + 99C1654F2C0C4F5900DC384D /* SSOWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C1654E2C0C4F5900DC384D /* SSOWebView.swift */; }; + 99C165512C0C4F7B00DC384D /* SSOWebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C165502C0C4F7B00DC384D /* SSOWebViewModel.swift */; }; BA8B3A322AD5487300D25EF5 /* SocialAuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA8B3A312AD5487300D25EF5 /* SocialAuthView.swift */; }; BADB3F552AD6DFC3004D5CFA /* SocialAuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADB3F542AD6DFC3004D5CFA /* SocialAuthViewModel.swift */; }; DE843D6BB1B9DDA398494890 /* Pods_App_Authorization.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 47BCFB7C19382EECF15131B6 /* Pods_App_Authorization.framework */; }; @@ -76,6 +80,10 @@ 7A84BB166492D4E46FBCF01C /* Pods-App-Authorization-AuthorizationTests.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization-AuthorizationTests.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Authorization-AuthorizationTests/Pods-App-Authorization-AuthorizationTests.debugdev.xcconfig"; sourceTree = ""; }; 90DFBB75EF40580E180D71C8 /* Pods-App-Authorization.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Authorization/Pods-App-Authorization.debugdev.xcconfig"; sourceTree = ""; }; 96C85172770225EB81A6D2DA /* Pods-App-Authorization.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization.releasedev.xcconfig"; path = "Target Support Files/Pods-App-Authorization/Pods-App-Authorization.releasedev.xcconfig"; sourceTree = ""; }; + 99C1654A2C0C4F0600DC384D /* ContainerWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContainerWebView.swift; sourceTree = ""; }; + 99C1654C2C0C4F2F00DC384D /* SSOHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSOHelper.swift; sourceTree = ""; }; + 99C1654E2C0C4F5900DC384D /* SSOWebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSOWebView.swift; sourceTree = ""; }; + 99C165502C0C4F7B00DC384D /* SSOWebViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SSOWebViewModel.swift; sourceTree = ""; }; 9BF6A1004A955E24527FCF0F /* Pods-App-Authorization-AuthorizationTests.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization-AuthorizationTests.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-Authorization-AuthorizationTests/Pods-App-Authorization-AuthorizationTests.releaseprod.xcconfig"; sourceTree = ""; }; A99D45203C981893C104053A /* Pods-App-Authorization-AuthorizationTests.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization-AuthorizationTests.releasestage.xcconfig"; path = "Target Support Files/Pods-App-Authorization-AuthorizationTests/Pods-App-Authorization-AuthorizationTests.releasestage.xcconfig"; sourceTree = ""; }; BA8B3A312AD5487300D25EF5 /* SocialAuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialAuthView.swift; sourceTree = ""; }; @@ -146,6 +154,7 @@ 071009CC28D1E24000344290 /* Presentation */ = { isa = PBXGroup; children = ( + 99C165492C0C4EF000DC384D /* SSO */, BA8B3A302AD5485100D25EF5 /* SocialAuth */, E03261622AE6464A002CA7EB /* Startup */, 020C31BD290AADA700D6DEA2 /* Base */, @@ -267,6 +276,17 @@ path = ../Pods; sourceTree = ""; }; + 99C165492C0C4EF000DC384D /* SSO */ = { + isa = PBXGroup; + children = ( + 99C1654A2C0C4F0600DC384D /* ContainerWebView.swift */, + 99C1654C2C0C4F2F00DC384D /* SSOHelper.swift */, + 99C1654E2C0C4F5900DC384D /* SSOWebView.swift */, + 99C165502C0C4F7B00DC384D /* SSOWebViewModel.swift */, + ); + path = SSO; + sourceTree = ""; + }; BA8B3A302AD5485100D25EF5 /* SocialAuth */ = { isa = PBXGroup; children = ( @@ -501,13 +521,17 @@ 02066B462906D72F00F4307E /* SignUpViewModel.swift in Sources */, E03261642AE64676002CA7EB /* StartupViewModel.swift in Sources */, 02A2ACDB2A4B016100FBBBBB /* AuthorizationAnalytics.swift in Sources */, + 99C165512C0C4F7B00DC384D /* SSOWebViewModel.swift in Sources */, + 99C1654B2C0C4F0600DC384D /* ContainerWebView.swift in Sources */, 025F40E029D1E2FC0064C183 /* ResetPasswordView.swift in Sources */, 020C31CB290BF49900D6DEA2 /* FieldsView.swift in Sources */, 0770DE4E28D0A677006D8A5D /* SignInView.swift in Sources */, 02F3BFE5292533720051930C /* AuthorizationRouter.swift in Sources */, + 99C1654F2C0C4F5900DC384D /* SSOWebView.swift in Sources */, E03261662AE64AF4002CA7EB /* StartupView.swift in Sources */, 071009C728D1DA4F00344290 /* SignInViewModel.swift in Sources */, BA8B3A322AD5487300D25EF5 /* SocialAuthView.swift in Sources */, + 99C1654D2C0C4F2F00DC384D /* SSOHelper.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -606,7 +630,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -717,7 +741,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -941,7 +965,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1034,7 +1058,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1132,7 +1156,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1225,7 +1249,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1381,7 +1405,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1416,7 +1440,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; diff --git a/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift b/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift index 9fcd13b69..9371a1b40 100644 --- a/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift +++ b/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift @@ -10,12 +10,15 @@ import Core public enum AuthMethod: Equatable { case password + case SSO case socailAuth(SocialAuthMethod) public var analyticsValue: String { switch self { case .password: "password" + case .SSO: + "SSO" case .socailAuth(let socialAuthMethod): socialAuthMethod.rawValue } diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index b3f8ef784..104b506cd 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -61,100 +61,167 @@ public struct SignInView: View { 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("", text: $email) - .font(Theme.Fonts.bodyLarge) - .foregroundColor(Theme.Colors.textInputTextColor) - .keyboardType(.emailAddress) - .textContentType(.emailAddress) - .autocapitalization(.none) - .autocorrectionDisabled() - .padding(.all, 14) - .background( - Theme.InputFieldBackground( - placeHolder: AuthLocalization.SignIn.emailOrUsername, - text: email, - padding: 15 + if viewModel.config.uiComponents.loginRegistrationEnabled { + 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("", text: $email) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textInputTextColor) + .keyboardType(.emailAddress) + .textContentType(.emailAddress) + .autocapitalization(.none) + .autocorrectionDisabled() + .padding(.all, 14) + .background( + Theme.InputFieldBackground( + placeHolder: AuthLocalization.SignIn.emailOrUsername, + text: email, + padding: 15 + ) ) - ) - .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("", text: $password) - .font(Theme.Fonts.bodyLarge) - .foregroundColor(Theme.Colors.textInputTextColor) - .padding(.all, 14) - .background( - Theme.InputFieldBackground( - placeHolder: AuthLocalization.SignIn.password, - text: password, - padding: 15 + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(Theme.Colors.textInputStroke) ) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill(Theme.Colors.textInputStroke) - ) - .accessibilityIdentifier("password_textfield") - HStack { - if !viewModel.config.features.startupScreenEnabled { - Button(CoreLocalization.register) { - viewModel.router.showRegisterScreen(sourceScreen: viewModel.sourceScreen) + .accessibilityIdentifier("username_textfield") + + Text(AuthLocalization.SignIn.password) + .font(Theme.Fonts.labelLarge) + .foregroundColor(Theme.Colors.textPrimary) + .padding(.top, 18) + .accessibilityIdentifier("password_text") + SecureField("", text: $password) + .font(Theme.Fonts.bodyLarge) + .foregroundColor(Theme.Colors.textInputTextColor) + .padding(.all, 14) + .background( + Theme.InputFieldBackground( + placeHolder: AuthLocalization.SignIn.password, + text: password, + padding: 15 + ) + ) + .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.infoColor) + .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("progress_bar") + }.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.infoColor) - .padding(.top, 0) - .accessibilityIdentifier("forgot_password_button") } - - if viewModel.isShowProgress { - HStack(alignment: .center) { - ProgressBar(size: 40, lineWidth: 8) - .padding(20) - .accessibilityIdentifier("progress_bar") - }.frame(maxWidth: .infinity) - } else { - StyledButton(CoreLocalization.SignIn.logInBtn) { - Task { - await viewModel.login(username: email, password: password) + if viewModel.config.uiComponents.samlSSOLoginEnabled { + if !viewModel.config.uiComponents.loginRegistrationEnabled{ + VStack(alignment: .center) { + Text(AuthLocalization.SignIn.ssoHeading) + .font(Theme.Fonts.headlineSmall) + .multilineTextAlignment(.center) + .foregroundColor(Theme.Colors.textPrimary) + .padding(.bottom, 4) + .padding(.horizontal, 20) + .accessibilityIdentifier("signin_sso_heading") + } + + Divider() + + VStack(alignment: .center) { + Text(AuthLocalization.SignIn.ssoLogInTitle) + .font(Theme.Fonts.headlineSmall) + .multilineTextAlignment(.center) + .foregroundColor(Theme.Colors.textPrimary) + .padding(.bottom, 10) + .padding(.horizontal, 20) + .accessibilityIdentifier("signin_sso_login_title") + + Text(AuthLocalization.SignIn.ssoLogInSubtitle) + .font(Theme.Fonts.titleMedium) + .multilineTextAlignment(.center) + .foregroundColor(Theme.Colors.textSecondaryLight) + .padding(.bottom, 10) + .padding(.horizontal, 20) + .accessibilityIdentifier("signin_sso_login_subtitle") + } + } + + VStack(alignment: .center) { + + if viewModel.isShowProgress { + HStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(20) + .accessibilityIdentifier("progressbar") + }.frame(maxWidth: .infinity) + } else { + let languageCode = Locale.current.language.languageCode?.identifier ?? "en" + if viewModel.config.uiComponents.samlSSODefaultLoginButton { + StyledButton(viewModel.config.ssoButtonTitle[languageCode] as! String, action: { + viewModel.router.showSSOWebBrowser(title: CoreLocalization.SignIn.logInBtn) + }) + .frame(maxWidth: .infinity) + .padding(.top, 20) + .accessibilityIdentifier("signin_SSO_button") + } else { + StyledButton(viewModel.config.ssoButtonTitle[languageCode] as! String, action: { + viewModel.router.showSSOWebBrowser(title: CoreLocalization.SignIn.logInBtn) + }, + color: .white, + textColor: Theme.Colors.accentColor, + borderColor: Theme.Colors.accentColor) + .frame(maxWidth: .infinity) + .padding(.top, 20) + .accessibilityIdentifier("signin_SSO_button") + } + } } - .frame(maxWidth: .infinity) - .padding(.top, 40) - .accessibilityIdentifier("signin_button") } } if viewModel.socialAuthEnabled { diff --git a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift index 5a87151f5..22040cd4e 100644 --- a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift +++ b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift @@ -88,6 +88,20 @@ public class SignInViewModel: ObservableObject { } } + @MainActor + func ssoLogin(title: String) async { + analytics.userSignInClicked() + isShowProgress = true + do { + let user = try await interactor.login(ssoToken: "") + analytics.identify(id: "\(user.id)", username: user.username, email: user.email) + analytics.userLogin(method: .password) + router.showMainOrWhatsNewScreen(sourceScreen: sourceScreen) + } catch let error { + failure(error) + } + } + @MainActor func login(with result: Result) async { switch result { diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index d63a3b9ef..a4a869383 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.register) + Text(CoreLocalization.SignIn.registerBtn) .titleSettings(color: Theme.Colors.loginNavigationText) .accessibilityIdentifier("register_text") } @@ -64,7 +64,7 @@ public struct SignUpView: View { ScrollView { VStack(alignment: .leading) { - Text(CoreLocalization.register) + Text(CoreLocalization.SignIn.registerBtn) .font(Theme.Fonts.displaySmall) .foregroundColor(Theme.Colors.textPrimary) .padding(.bottom, 4) diff --git a/Authorization/Authorization/Presentation/SSO/ContainerWebView.swift b/Authorization/Authorization/Presentation/SSO/ContainerWebView.swift new file mode 100644 index 000000000..476630cf6 --- /dev/null +++ b/Authorization/Authorization/Presentation/SSO/ContainerWebView.swift @@ -0,0 +1,46 @@ +// +// ContainerWebView.swift +// Authorization +// +// Created by Rawan Matar on 02/06/2024. +// + +import SwiftUI +import Core +import Swinject + +public struct ContainerWebView: View { + + // MARK: - Internal Properties + + let url: String + private var pageTitle: String + @Environment(\.presentationMode) var presentationMode + + // MARK: - Init + + public init(_ url: String, title: String) { + self.url = url + self.pageTitle = title + } + + // MARK: - UI + + public var body: some View { + VStack(alignment: .center) { + NavigationBar( + title: pageTitle, + leftButtonAction: { presentationMode.wrappedValue.dismiss() } + ) + + ZStack { + if !url.isEmpty { + SSOWebView(url: URL(string: url), viewModel: Container.shared.resolve(SSOWebViewModel.self)!) + } else { + EmptyView() + } + } + .accessibilityIdentifier("web_browser") + } + } +} diff --git a/Authorization/Authorization/Presentation/SSO/SSOHelper.swift b/Authorization/Authorization/Presentation/SSO/SSOHelper.swift new file mode 100644 index 000000000..1f195a153 --- /dev/null +++ b/Authorization/Authorization/Presentation/SSO/SSOHelper.swift @@ -0,0 +1,80 @@ +// +// SSOHelper.swift +// Authorization +// +// Created by Rawan Matar on 02/06/2024. +// + +import Foundation +import KeychainSwift + +// https://developer.apple.com/documentation/ios-ipados-release-notes/foundation-release-notes + +/** + A Helper for some of the SSO preferences. + Keeps data under the UserDefaults. + */ +public class SSOHelper: NSObject { + + private let keychain: KeychainSwift + public enum SSOHelperKeys: String, CaseIterable { + case cookiePayload + case cookieSignature + case userInfo + + var description: String { + switch self { + case .cookiePayload: + return "edx-jwt-cookie-header-payload" + case .cookieSignature: + return "edx-jwt-cookie-signature" + case .userInfo: + return "edx-user-info" + } + } + } + + public init(keychain: KeychainSwift) { + self.keychain = keychain + } + // MARK: - Public Properties + + /// Authentication + public var cookiePayload: String? { + get { + let defaults = UserDefaults.standard + return keychain.get(SSOHelperKeys.cookiePayload.rawValue) + } + set(newValue) { + if let newValue { + keychain.set(newValue, forKey: SSOHelperKeys.cookiePayload.rawValue) + } else { + keychain.delete(SSOHelperKeys.cookiePayload.rawValue) + } + } + } + + /// Authentication + public var cookieSignature: String? { + get { + let defaults = UserDefaults.standard + return keychain.get(SSOHelperKeys.cookieSignature.rawValue) + } + set(newValue) { + if let newValue { + keychain.set(newValue, forKey: SSOHelperKeys.cookieSignature.rawValue) + } else { + keychain.delete(SSOHelperKeys.cookieSignature.rawValue) + } + } + } + + // MARK: - Public Methods + + /// Checks if the user is login. + public func cleanAfterSuccesfulLogout() { + cookiePayload = nil + cookieSignature = nil + } +} + diff --git a/Authorization/Authorization/Presentation/SSO/SSOWebView.swift b/Authorization/Authorization/Presentation/SSO/SSOWebView.swift new file mode 100644 index 000000000..d7a89cf7b --- /dev/null +++ b/Authorization/Authorization/Presentation/SSO/SSOWebView.swift @@ -0,0 +1,91 @@ +// +// SSOWebView.swift +// Authorization +// +// Created by Rawan Matar on 02/06/2024. +// +import SwiftUI +@preconcurrency import WebKit +import Core + +public struct SSOWebView: UIViewRepresentable { + + let url: URL? + + var viewModel: SSOWebViewModel + + public init(url: URL?, viewModel: SSOWebViewModel) { + self.url = url + self.viewModel = viewModel + } + + public func makeUIView(context: Context) -> WKWebView { + let coordinator = makeCoordinator() + let userContentController = WKUserContentController() + userContentController.add(coordinator, name: "bridge") + + let prefs = WKWebpagePreferences() + let config = WKWebViewConfiguration() + prefs.allowsContentJavaScript = true + + config.userContentController = userContentController + config.defaultWebpagePreferences = prefs + config.websiteDataStore = WKWebsiteDataStore.nonPersistent() + + let wkWebView = WKWebView(frame: .zero, configuration: config) + wkWebView.navigationDelegate = coordinator + + guard let currentURL = url else { + return wkWebView + } + let request = URLRequest(url: currentURL) + wkWebView.load(request) + + return wkWebView + } + + public func updateUIView(_ uiView: WKWebView, context: Context) { + + } + + public func makeCoordinator() -> Coordinator { + Coordinator(viewModel: self.viewModel) + } + + public class Coordinator: NSObject, WKScriptMessageHandler, WKNavigationDelegate { + var viewModel: SSOWebViewModel + + init(viewModel: SSOWebViewModel) { + self.viewModel = viewModel + super.init() + } + + // WKScriptMessageHandler + public func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) { + } + + // WKNavigationDelegate + public func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) { + if webView.url?.absoluteString == nil { + return + } + } + + public func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) { + guard let url = webView.url?.absoluteString else { + decisionHandler(.allow) + return + } + + if url.contains(viewModel.config.ssoFinishedURL.absoluteString) { + webView.configuration.websiteDataStore.httpCookieStore.getAllCookies { cookies in + Task { + await self.viewModel.SSOLogin(cookies: cookies) + } + } + } + + decisionHandler(.allow) + } + } +} diff --git a/Authorization/Authorization/Presentation/SSO/SSOWebViewModel.swift b/Authorization/Authorization/Presentation/SSO/SSOWebViewModel.swift new file mode 100644 index 000000000..043a34060 --- /dev/null +++ b/Authorization/Authorization/Presentation/SSO/SSOWebViewModel.swift @@ -0,0 +1,119 @@ +// +// SSOWebViewModel.swift +// Authorization +// +// Created by Rawan Matar on 02/06/2024. +// + +import Foundation +import SwiftUI +import Core +import Alamofire +import AuthenticationServices +import FacebookLogin +import GoogleSignIn +import MSAL + +public class SSOWebViewModel: ObservableObject { + + @Published private(set) var isShowProgress = false + @Published private(set) var showError: Bool = false + @Published private(set) var showAlert: Bool = false + let sourceScreen: LogistrationSourceScreen = .default + + var errorMessage: String? { + didSet { + withAnimation { + showError = errorMessage != nil + } + } + } + var alertMessage: String? { + didSet { + withAnimation { + showAlert = alertMessage != nil + } + } + } + + let router: AuthorizationRouter + let config: ConfigProtocol + private let interactor: AuthInteractorProtocol + private let analytics: AuthorizationAnalytics + let ssoHelper: SSOHelper + + public init( + interactor: AuthInteractorProtocol, + router: AuthorizationRouter, + config: ConfigProtocol, + analytics: AuthorizationAnalytics, + ssoHelper: SSOHelper + ) { + self.interactor = interactor + self.router = router + self.config = config + self.analytics = analytics + self.ssoHelper = ssoHelper + } + + @MainActor + func SSOLogin(cookies: [HTTPCookie]) async { + guard !cookies.isEmpty else { + errorMessage = "COOKIES EMPTY" + return + } + + isShowProgress = true + for cookie in cookies { + /// Store cookies in UserDefaults + if cookie.name == SSOHelper.SSOHelperKeys.cookiePayload.description { + self.ssoHelper.cookiePayload = cookie.value + } + + if cookie.name == SSOHelper.SSOHelperKeys.cookieSignature.description { + self.ssoHelper.cookieSignature = cookie.value + } + if let signature = self.ssoHelper.cookieSignature, + let payload = self.ssoHelper.cookiePayload { + isShowProgress = true + do { + let user = try await interactor.login(ssoToken: "\(payload).\(signature)") + analytics.identify(id: "\(user.id)", username: user.username, email: user.email) + analytics.userLogin(method: .SSO) + router.showMainOrWhatsNewScreen(sourceScreen: sourceScreen) + } catch let error { + failure(error, authMethod: .SSO) + } + } + } + } + + @MainActor + private func failure(_ error: Error, authMethod: AuthMethod? = nil) { + isShowProgress = false + if let validationError = error.validationError, + let value = validationError.data?["error_description"] as? String { + if authMethod != .password, validationError.statusCode == 400, let authMethod = authMethod { + errorMessage = AuthLocalization.Error.accountNotRegistered( + authMethod.analyticsValue, + config.platformName + ) + } else if validationError.statusCode == 403 { + errorMessage = AuthLocalization.Error.disabledAccount + } else { + errorMessage = value + } + } else if case APIError.invalidGrant = error { + errorMessage = CoreLocalization.Error.invalidCredentials + } else if error.isInternetError { + errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } else { + errorMessage = CoreLocalization.Error.unknownError + } + } + + func trackForgotPasswordClicked() { + analytics.forgotPasswordClicked() + } + +} diff --git a/Authorization/Authorization/Presentation/Startup/StartupView.swift b/Authorization/Authorization/Presentation/Startup/StartupView.swift index 59d5772b4..5d762f98b 100644 --- a/Authorization/Authorization/Presentation/Startup/StartupView.swift +++ b/Authorization/Authorization/Presentation/Startup/StartupView.swift @@ -105,6 +105,8 @@ public struct StartupView: View { switch buttonAction { case .signIn: viewModel.router.showLoginScreen(sourceScreen: .startup) + case .signInWithSSO: + viewModel.router.showLoginScreen(sourceScreen: .startup) case .register: viewModel.router.showRegisterScreen(sourceScreen: .startup) } diff --git a/Authorization/Authorization/SwiftGen/Strings.swift b/Authorization/Authorization/SwiftGen/Strings.swift index d139b4a0e..e9358164c 100644 --- a/Authorization/Authorization/SwiftGen/Strings.swift +++ b/Authorization/Authorization/SwiftGen/Strings.swift @@ -69,6 +69,14 @@ 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") + /// Start today to build your career with confidence + public static let ssoHeading = AuthLocalization.tr("Localizable", "SIGN_IN.SSO_HEADING", fallback: "Start today to build your career with confidence") + /// Log in through the national unified sign-on service + public static let ssoLogInSubtitle = AuthLocalization.tr("Localizable", "SIGN_IN.SSO_LOG_IN_SUBTITLE", fallback: "Log in through the national unified sign-on service") + /// Sign IN + public static let ssoLogInTitle = AuthLocalization.tr("Localizable", "SIGN_IN.SSO_LOG_IN_TITLE", fallback: "Sign IN") + /// An integrated set of knowledge and empowerment programs to develop the components of the endowment sector and its workers + public static let ssoSupportingText = AuthLocalization.tr("Localizable", "SIGN_IN.SSO_SUPPORTING_TEXT", fallback: "An integrated set of knowledge and empowerment programs to develop the components of the endowment sector and its workers") /// 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.") } diff --git a/Authorization/Authorization/en.lproj/Localizable.strings b/Authorization/Authorization/en.lproj/Localizable.strings index f15da07ce..5285b05e6 100644 --- a/Authorization/Authorization/en.lproj/Localizable.strings +++ b/Authorization/Authorization/en.lproj/Localizable.strings @@ -14,7 +14,10 @@ "SIGN_IN.FORGOT_PASS_BTN" = "Forgot password?"; "SIGN_IN.AGREEMENT" = "By signing in to this app, 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 in accordance with the [Privacy Policy.](%@)"; - +"SIGN_IN.SSO_HEADING" = "Start today to build your career with confidence"; +"SIGN_IN.SSO_SUPPORTING_TEXT" = "An integrated set of knowledge and empowerment programs to develop the components of the endowment sector and its workers"; +"SIGN_IN.SSO_LOG_IN_TITLE" = "Sign in"; +"SIGN_IN.SSO_LOG_IN_SUBTITLE" = "Log in through the national unified sign-on service"; "ERROR.INVALID_EMAIL_ADDRESS" = "Invalid email address"; "ERROR.INVALID_PASSWORD_LENGHT" = "Invalid password lenght"; diff --git a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift index a6520c298..b6106d4d0 100644 --- a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift +++ b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift @@ -93,6 +93,23 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { return __value } + @discardableResult + open func login(ssoToken: String) async throws -> Core.User { + addInvocation(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) + let perform = methodPerformValue(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) as? (String) -> Void + perform?(`ssoToken`) + var __value: User + do { + __value = try methodReturnValue(.m_login__SSO__username_password(Parameter.value(ssoToken))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for login(username: String, password: String). Use given") + Failure("Stub return value not specified for login(username: String, password: String). Use given") + } catch { + throw error + } + return __value + } + open func resetPassword(email: String) throws -> ResetPassword { addInvocation(.m_resetPassword__email_email(Parameter.value(`email`))) let perform = methodPerformValue(.m_resetPassword__email_email(Parameter.value(`email`))) as? (String) -> Void @@ -173,6 +190,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { fileprivate enum MethodType { case m_login__username_usernamepassword_password(Parameter, Parameter) + case m_login__SSO__username_password(Parameter) case m_login__externalToken_externalTokenbackend_backend(Parameter, Parameter) case m_resetPassword__email_email(Parameter) case m_getCookies__force_force(Parameter) @@ -188,6 +206,11 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsPassword, rhs: rhsPassword, with: matcher), lhsPassword, rhsPassword, "password")) return Matcher.ComparisonResult(results) + case (.m_login__SSO__username_password(let lhsJwtToken), .m_login__SSO__username_password(let rhsJwtToken)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsJwtToken, rhs: rhsJwtToken, with: matcher), lhsJwtToken, rhsJwtToken, "jwtToken")) + return Matcher.ComparisonResult(results) + case (.m_login__externalToken_externalTokenbackend_backend(let lhsExternaltoken, let lhsBackend), .m_login__externalToken_externalTokenbackend_backend(let rhsExternaltoken, let rhsBackend)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsExternaltoken, rhs: rhsExternaltoken, with: matcher), lhsExternaltoken, rhsExternaltoken, "externalToken")) @@ -223,6 +246,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func intValue() -> Int { switch self { case let .m_login__username_usernamepassword_password(p0, p1): return p0.intValue + p1.intValue + case let .m_login__SSO__username_password(p0): return p0.intValue case let .m_login__externalToken_externalTokenbackend_backend(p0, p1): return p0.intValue + p1.intValue case let .m_resetPassword__email_email(p0): return p0.intValue case let .m_getCookies__force_force(p0): return p0.intValue @@ -234,6 +258,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func assertionName() -> String { switch self { case .m_login__username_usernamepassword_password: return ".login(username:password:)" + case .m_login__SSO__username_password: return ".loginSSO(username:password:)" case .m_login__externalToken_externalTokenbackend_backend: return ".login(externalToken:backend:)" case .m_resetPassword__email_email: return ".resetPassword(email:)" case .m_getCookies__force_force: return ".getCookies(force:)" @@ -261,6 +286,10 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func login(externalToken: Parameter, backend: Parameter, willReturn: User...) -> MethodStub { return Given(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + @discardableResult + public static func ssoLogin(title: Parameter, willReturn: User...) -> MethodStub { + return Given(method: .m_login__SSO__username_password(`title`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func resetPassword(email: Parameter, willReturn: ResetPassword...) -> MethodStub { return Given(method: .m_resetPassword__email_email(`email`), products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -353,7 +382,8 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { fileprivate var method: MethodType @discardableResult - public static func login(username: Parameter, password: Parameter) -> Verify { return Verify(method: .m_login__username_usernamepassword_password(`username`, `password`))} + public static func login(username: Parameter, password: Parameter) -> Verify { return Verify(method: .m_login__username_usernamepassword_password(`username`, `password`))} + public static func ssoLogin(title: Parameter) -> Verify { return Verify(method: .m_login__SSO__username_password(`title`))} @discardableResult public static func login(externalToken: Parameter, backend: Parameter) -> Verify { return Verify(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`))} public static func resetPassword(email: Parameter) -> Verify { return Verify(method: .m_resetPassword__email_email(`email`))} @@ -579,6 +609,7 @@ 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_ssoLogin__method_method(Parameter) case m_registerClicked case m_signInClicked case m_userSignInClicked @@ -638,6 +669,7 @@ 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 let .m_ssoLogin__method_method(p0): return p0.intValue case .m_registerClicked: return 0 case .m_signInClicked: return 0 case .m_userSignInClicked: return 0 @@ -653,6 +685,7 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { switch self { case .m_identify__id_idusername_usernameemail_email: return ".identify(id:username:email:)" case .m_userLogin__method_method: return ".userLogin(method:)" + case .m_ssoLogin__method_method: return ".ssoLogin(method:)" case .m_registerClicked: return ".registerClicked()" case .m_signInClicked: return ".signInClicked()" case .m_userSignInClicked: return ".userSignInClicked()" @@ -682,6 +715,7 @@ 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 ssoLogin(method: Parameter) -> Verify { return Verify(method: .m_ssoLogin__method_method(`method`))} 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)} @@ -927,6 +961,12 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { perform?(`title`, `url`) } + open func showSSOWebBrowser(title: String) { + addInvocation(.m_showWebBrowser__SSO(Parameter.value(`title`))) + let perform = methodPerformValue(.m_showWebBrowser__SSO(Parameter.value(`title`))) as? (String) -> Void + perform?(`title`) + } + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -966,6 +1006,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { case m_showForgotPasswordScreen case m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(Parameter, Parameter) case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) + case m_showWebBrowser__SSO(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_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) @@ -1085,6 +1126,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { case .m_showForgotPasswordScreen: return 0 case let .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(p0, p1): return p0.intValue + p1.intValue case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue + case let .m_showWebBrowser__SSO(p0): return p0.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_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue @@ -1106,6 +1148,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen: return ".showDiscoveryScreen(searchQuery:sourceScreen:)" case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" + case .m_showWebBrowser__SSO: return ".showSSOWebBrowser(title:)" 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_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" @@ -1190,6 +1233,9 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { public static func showWebBrowser(title: Parameter, url: Parameter, perform: @escaping (String, URL) -> Void) -> Perform { return Perform(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`), performs: perform) } + public static func showSSOWebBrowser(title: Parameter, perform: @escaping (String, URL) -> Void) -> Perform { + return Perform(method: .m_showWebBrowser__SSO(`title`), performs: perform) + } public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } @@ -1389,8 +1435,14 @@ open class BaseRouterMock: BaseRouter, Mock { open func showWebBrowser(title: String, url: URL) { addInvocation(.m_showWebBrowser__title_titleurl_url(Parameter.value(`title`), Parameter.value(`url`))) - let perform = methodPerformValue(.m_showWebBrowser__title_titleurl_url(Parameter.value(`title`), Parameter.value(`url`))) as? (String, URL) -> Void - perform?(`title`, `url`) + let perform = methodPerformValue(.m_showWebBrowser__title_titleurl_url(Parameter.value(`title`), Parameter.value(`url`))) as? (String, URL) -> Void + perform?(`title`, `url`) + } + + open func showSSOWebBrowser(title: String) { + addInvocation(.m_showWebBrowser__SSO(Parameter.value(`title`))) + let perform = methodPerformValue(.m_showWebBrowser__SSO(Parameter.value(`title`))) as? (String) -> Void + perform?(`title`) } open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { @@ -1431,6 +1483,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showForgotPasswordScreen case m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(Parameter, Parameter) case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) + case m_showWebBrowser__SSO(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_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) @@ -1544,6 +1597,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return 0 case let .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(p0, p1): return p0.intValue + p1.intValue case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue + case let .m_showWebBrowser__SSO(p0): return p0.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_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue @@ -1564,6 +1618,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen: return ".showDiscoveryScreen(searchQuery:sourceScreen:)" case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" + case .m_showWebBrowser__SSO: return ".showSSOWebBrowser(title:)" 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_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" diff --git a/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift index e824ad975..371ac62b7 100644 --- a/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift @@ -93,7 +93,33 @@ final class SignInViewModelTests: XCTestCase { XCTAssertEqual(viewModel.errorMessage, nil) XCTAssertEqual(viewModel.isShowProgress, true) } - + + func testSSOLoginSuccess() async throws { + let interactor = AuthInteractorProtocolMock() + let router = AuthorizationRouterMock() + let validator = Validator() + let analytics = AuthorizationAnalyticsMock() + let viewModel = SignInViewModel( + interactor: interactor, + router: router, + config: ConfigMock(), + analytics: analytics, + validator: validator, + sourceScreen: .default + ) + let user = User(id: 1, username: "username", email: "edxUser@edx.com", name: "Name", userAvatar: "") + + Given(interactor, .ssoLogin(title: .any, willReturn: user)) + + await viewModel.ssoLogin(title: "Riyadah") + + Verify(interactor, 1, .ssoLogin(title: .any)) + Verify(router, 1, .showMainOrWhatsNewScreen(sourceScreen: .any)) + + XCTAssertEqual(viewModel.errorMessage, nil) + XCTAssertEqual(viewModel.isShowProgress, true) + } + func testSocialLoginSuccess() async throws { let interactor = AuthInteractorProtocolMock() let router = AuthorizationRouterMock() diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index e8b75069a..f1f58e740 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -1413,7 +1413,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1446,7 +1446,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; @@ -1528,7 +1528,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1560,7 +1560,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; @@ -1581,7 +1581,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; @@ -1603,7 +1603,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; @@ -1625,7 +1625,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; @@ -1647,7 +1647,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; @@ -1668,7 +1668,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; @@ -1689,7 +1689,7 @@ CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 16.4; MARKETING_VERSION = 1.0; @@ -1776,7 +1776,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1869,7 +1869,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1967,7 +1967,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -2060,7 +2060,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -2216,7 +2216,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -2251,7 +2251,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; diff --git a/Core/Core/Configuration/BaseRouter.swift b/Core/Core/Configuration/BaseRouter.swift index e2a2a714b..4f2ae5bba 100644 --- a/Core/Core/Configuration/BaseRouter.swift +++ b/Core/Core/Configuration/BaseRouter.swift @@ -34,6 +34,8 @@ public protocol BaseRouter { func showDiscoveryScreen(searchQuery: String?, sourceScreen: LogistrationSourceScreen) func showWebBrowser(title: String, url: URL) + + func showSSOWebBrowser(title: String) func presentAlert( alertTitle: String, @@ -100,6 +102,8 @@ open class BaseRouterMock: BaseRouter { public func removeLastView(controllers: Int) {} public func showWebBrowser(title: String, url: URL) {} + + public func showSSOWebBrowser(title: String) {} public func presentAlert( alertTitle: String, diff --git a/Core/Core/Configuration/Config/Config.swift b/Core/Core/Configuration/Config/Config.swift index be5fd1941..8c0cd2d18 100644 --- a/Core/Core/Configuration/Config/Config.swift +++ b/Core/Core/Configuration/Config/Config.swift @@ -9,6 +9,9 @@ import Foundation public protocol ConfigProtocol { var baseURL: URL { get } + var baseSSOURL: URL { get } + var ssoFinishedURL: URL { get } + var ssoButtonTitle: [String: Any] { get } var oAuthClientId: String { get } var tokenType: TokenType { get } var feedbackEmail: String { get } @@ -41,6 +44,9 @@ public enum TokenType: String { private enum ConfigKeys: String { case baseURL = "API_HOST_URL" + case ssoBaseURL = "SSO_URL" + case ssoFinishedURL = "SSO_FINISHED_URL" + case ssoButtonTitle = "SSO_BUTTON_TITLE" case oAuthClientID = "OAUTH_CLIENT_ID" case tokenType = "TOKEN_TYPE" case feedbackEmailAddress = "FEEDBACK_EMAIL_ADDRESS" @@ -120,6 +126,29 @@ extension Config: ConfigProtocol { return url } + public var baseSSOURL: URL { + guard let urlString = string(for: ConfigKeys.ssoBaseURL.rawValue), + let url = URL(string: urlString) else { + fatalError("Unable to find SSO base url in config.") + } + return url + } + + public var ssoFinishedURL: URL { + guard let urlString = string(for: ConfigKeys.ssoFinishedURL.rawValue), + let url = URL(string: urlString) else { + fatalError("Unable to find SSO successful login url in config.") + } + return url + } + + public var ssoButtonTitle: [String: Any] { + guard let ssoButtonTitle = dict(for: ConfigKeys.ssoButtonTitle.rawValue) else { + return ["en": CoreLocalization.SignIn.logInWithSsoBtn] + } + return ssoButtonTitle + } + public var oAuthClientId: String { guard let clientID = string(for: ConfigKeys.oAuthClientID.rawValue) else { fatalError("Unable to find OAuth ClientID in config.") @@ -168,6 +197,7 @@ extension Config: ConfigProtocol { public class ConfigMock: Config { private let config: [String: Any] = [ "API_HOST_URL": "https://www.example.com", + "SSO_URL" : "https://www.example.com", "OAUTH_CLIENT_ID": "oauth_client_id", "FEEDBACK_EMAIL_ADDRESS": "example@mail.com", "PLATFORM_NAME": "OpenEdx", diff --git a/Core/Core/Configuration/Config/UIComponentsConfig.swift b/Core/Core/Configuration/Config/UIComponentsConfig.swift index 1b8cf788e..16c42ad4c 100644 --- a/Core/Core/Configuration/Config/UIComponentsConfig.swift +++ b/Core/Core/Configuration/Config/UIComponentsConfig.swift @@ -10,15 +10,24 @@ import Foundation private enum Keys: String, RawStringExtractable { case courseDropDownNavigationEnabled = "COURSE_DROPDOWN_NAVIGATION_ENABLED" case courseUnitProgressEnabled = "COURSE_UNIT_PROGRESS_ENABLED" + case loginRegistrationEnabled = "LOGIN_REGISTRATION_ENABLED" + case samlSSOLoginEnabled = "SAML_SSO_LOGIN_ENABLED" + case samlSSODefaultLoginButton = "SAML_SSO_DEFAULT_LOGIN_BUTTON" } public class UIComponentsConfig: NSObject { public var courseDropDownNavigationEnabled: Bool public var courseUnitProgressEnabled: Bool + public var loginRegistrationEnabled: Bool + public var samlSSOLoginEnabled: Bool + public var samlSSODefaultLoginButton: Bool init(dictionary: [String: Any]) { courseDropDownNavigationEnabled = dictionary[Keys.courseDropDownNavigationEnabled] as? Bool ?? false courseUnitProgressEnabled = dictionary[Keys.courseUnitProgressEnabled] as? Bool ?? false + loginRegistrationEnabled = dictionary[Keys.loginRegistrationEnabled] as? Bool ?? true + samlSSOLoginEnabled = dictionary[Keys.samlSSOLoginEnabled] as? Bool ?? false + samlSSODefaultLoginButton = dictionary[Keys.samlSSODefaultLoginButton] as? Bool ?? false super.init() } } diff --git a/Core/Core/Data/Repository/AuthRepository.swift b/Core/Core/Data/Repository/AuthRepository.swift index d00441ee6..2c838e679 100644 --- a/Core/Core/Data/Repository/AuthRepository.swift +++ b/Core/Core/Data/Repository/AuthRepository.swift @@ -10,6 +10,7 @@ import Foundation public protocol AuthRepositoryProtocol { func login(username: String, password: String) async throws -> User func login(externalToken: String, backend: String) async throws -> User + func login(ssoToken: String) async throws -> User func getCookies(force: Bool) async throws func getRegistrationFields() async throws -> [PickerFields] func registerUser(fields: [String: String], isSocial: Bool) async throws -> User @@ -80,6 +81,16 @@ public class AuthRepository: AuthRepositoryProtocol { return user.domain } + public func login(ssoToken: String) async throws -> User { + if appStorage.accessToken == nil { + appStorage.accessToken = ssoToken + } + + let user = try await api.requestData(AuthEndpoint.getUserInfo).mapResponse(DataLayer.User.self) + appStorage.user = user + return user.domain + } + public func resetPassword(email: String) async throws -> ResetPassword { let response = try await api.requestData(AuthEndpoint.resetPassword(email: email)) .mapResponse(DataLayer.ResetPassword.self) @@ -137,6 +148,10 @@ class AuthRepositoryMock: AuthRepositoryProtocol { User(id: 1, username: "User", email: "email@gmail.com", name: "User Name", userAvatar: "") } + func login(ssoToken: String) async throws -> User { + return User(id: 1, username: "User", email: "email@gmail.com", name: "User Name", userAvatar: "") + } + func resetPassword(email: String) async throws -> ResetPassword { ResetPassword(success: true, responseText: "Success reset") } diff --git a/Core/Core/Domain/AuthInteractor.swift b/Core/Core/Domain/AuthInteractor.swift index 45868cbc9..6b3562f20 100644 --- a/Core/Core/Domain/AuthInteractor.swift +++ b/Core/Core/Domain/AuthInteractor.swift @@ -13,6 +13,7 @@ public protocol AuthInteractorProtocol { func login(username: String, password: String) async throws -> User @discardableResult func login(externalToken: String, backend: String) async throws -> User + func login(ssoToken: String) async throws -> User func resetPassword(email: String) async throws -> ResetPassword func getCookies(force: Bool) async throws func getRegistrationFields() async throws -> [PickerFields] @@ -37,6 +38,11 @@ public class AuthInteractor: AuthInteractorProtocol { return try await repository.login(externalToken: externalToken, backend: backend) } + @discardableResult + public func login(ssoToken: String) async throws -> User { + return try await repository.login(ssoToken: ssoToken) + } + public func resetPassword(email: String) async throws -> ResetPassword { try await repository.resetPassword(email: email) } diff --git a/Core/Core/SwiftGen/Strings.swift b/Core/Core/SwiftGen/Strings.swift index ed9aa825d..e42e1dd03 100644 --- a/Core/Core/SwiftGen/Strings.swift +++ b/Core/Core/SwiftGen/Strings.swift @@ -14,8 +14,6 @@ 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 @@ -290,6 +288,10 @@ public enum CoreLocalization { public enum SignIn { /// Sign in public static let logInBtn = CoreLocalization.tr("Localizable", "SIGN_IN.LOG_IN_BTN", fallback: "Sign in") + /// Sign in with SSO + public static let logInWithSsoBtn = CoreLocalization.tr("Localizable", "SIGN_IN.LOG_IN_WITH_SSO_BTN", fallback: "Sign in with SSO") + /// 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/LogistrationBottomView.swift b/Core/Core/View/Base/LogistrationBottomView.swift index fc0aa0ef4..faa3aaa35 100644 --- a/Core/Core/View/Base/LogistrationBottomView.swift +++ b/Core/Core/View/Base/LogistrationBottomView.swift @@ -15,14 +15,11 @@ 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 { case signIn + case signInWithSSO case register } @@ -34,11 +31,11 @@ public struct LogistrationBottomView: View { public init(_ action: @escaping (LogistrationAction) -> Void) { self.action = action } - + public var body: some View { VStack(alignment: .leading) { HStack(spacing: 24) { - StyledButton(CoreLocalization.register) { + StyledButton(CoreLocalization.SignIn.registerBtn) { action(.register) } .accessibilityIdentifier("logistration_register_button") @@ -54,6 +51,18 @@ public struct LogistrationBottomView: View { ) .frame(width: 100) .accessibilityIdentifier("logistration_signin_button") + + StyledButton( + CoreLocalization.SignIn.logInWithSsoBtn, + action: { + action(.signInWithSSO) + }, + color: Theme.Colors.white, + textColor: Theme.Colors.secondaryButtonTextColor, + borderColor: Theme.Colors.secondaryButtonBorderColor + ) + .frame(width: 100) + .accessibilityIdentifier("logistration_signin_withsso_button") } .padding(.horizontal, isHorizontal ? 0 : 0) } diff --git a/Core/Core/en.lproj/Localizable.strings b/Core/Core/en.lproj/Localizable.strings index b7bc68aad..55f7e55dd 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -120,7 +120,8 @@ "SOCIAL_SIGN_CANCELED" = "The user canceled the sign-in flow."; "SIGN_IN.LOG_IN_BTN" = "Sign in"; -"REGISTER" = "Register"; +"SIGN_IN.REGISTER_BTN" = "Register"; +"SIGN_IN.LOG_IN_WITH_SSO_BTN" = "Sign in with SSO"; "TOMORROW" = "Tomorrow"; "YESTERDAY" = "Yesterday"; diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index a04ee6650..737558bc2 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -1258,7 +1258,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1293,7 +1293,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1391,7 +1391,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1490,7 +1490,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1583,7 +1583,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1675,7 +1675,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1773,7 +1773,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1887,7 +1887,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index f6bb64618..f3618b378 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -77,7 +77,24 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { } @discardableResult - open func login(externalToken: String, backend: String) throws -> User { + open func login(ssoToken: String) async throws -> Core.User { + addInvocation(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) + let perform = methodPerformValue(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) as? (String) -> Void + perform?(`ssoToken`) + var __value: User + do { + __value = try methodReturnValue(.m_login__SSO__username_password(Parameter.value(ssoToken))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for login(username: String, password: String). Use given") + Failure("Stub return value not specified for login(username: String, password: String). Use given") + } catch { + throw error + } + return __value + } + + @discardableResult + open func login(externalToken: String, backend: String) throws -> User { addInvocation(.m_login__externalToken_externalTokenbackend_backend(Parameter.value(`externalToken`), Parameter.value(`backend`))) let perform = methodPerformValue(.m_login__externalToken_externalTokenbackend_backend(Parameter.value(`externalToken`), Parameter.value(`backend`))) as? (String, String) -> Void perform?(`externalToken`, `backend`) @@ -173,6 +190,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { fileprivate enum MethodType { case m_login__username_usernamepassword_password(Parameter, Parameter) + case m_login__SSO__username_password(Parameter) case m_login__externalToken_externalTokenbackend_backend(Parameter, Parameter) case m_resetPassword__email_email(Parameter) case m_getCookies__force_force(Parameter) @@ -223,6 +241,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func intValue() -> Int { switch self { case let .m_login__username_usernamepassword_password(p0, p1): return p0.intValue + p1.intValue + case let .m_login__SSO__username_password(p0): return p0.intValue case let .m_login__externalToken_externalTokenbackend_backend(p0, p1): return p0.intValue + p1.intValue case let .m_resetPassword__email_email(p0): return p0.intValue case let .m_getCookies__force_force(p0): return p0.intValue @@ -234,6 +253,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func assertionName() -> String { switch self { case .m_login__username_usernamepassword_password: return ".login(username:password:)" + case .m_login__SSO__username_password: return ".loginSSO(username:password:)" case .m_login__externalToken_externalTokenbackend_backend: return ".login(externalToken:backend:)" case .m_resetPassword__email_email: return ".resetPassword(email:)" case .m_getCookies__force_force: return ".getCookies(force:)" @@ -577,10 +597,16 @@ open class BaseRouterMock: BaseRouter, Mock { open func showWebBrowser(title: String, url: URL) { addInvocation(.m_showWebBrowser__title_titleurl_url(Parameter.value(`title`), Parameter.value(`url`))) - let perform = methodPerformValue(.m_showWebBrowser__title_titleurl_url(Parameter.value(`title`), Parameter.value(`url`))) as? (String, URL) -> Void - perform?(`title`, `url`) + let perform = methodPerformValue(.m_showWebBrowser__title_titleurl_url(Parameter.value(`title`), Parameter.value(`url`))) as? (String, URL) -> Void + perform?(`title`, `url`) } - + + open func showSSOWebBrowser(title: String) { + addInvocation(.m_showWebBrowser__SSO(Parameter.value(`title`))) + let perform = methodPerformValue(.m_showWebBrowser__SSO(Parameter.value(`title`))) as? (String) -> Void + perform?(`title`) + } + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -619,6 +645,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showForgotPasswordScreen case m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(Parameter, Parameter) case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) + case m_showWebBrowser__SSO(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_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) @@ -732,6 +759,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return 0 case let .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(p0, p1): return p0.intValue + p1.intValue case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue + case let .m_showWebBrowser__SSO(p0): return p0.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_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue @@ -752,6 +780,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen: return ".showDiscoveryScreen(searchQuery:sourceScreen:)" case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" + case .m_showWebBrowser__SSO: return ".showSSOWebBrowser(title:)" 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_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" diff --git a/Dashboard/Dashboard.xcodeproj/project.pbxproj b/Dashboard/Dashboard.xcodeproj/project.pbxproj index 2ae0ee9bd..625c0d2f0 100644 --- a/Dashboard/Dashboard.xcodeproj/project.pbxproj +++ b/Dashboard/Dashboard.xcodeproj/project.pbxproj @@ -736,7 +736,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -850,7 +850,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1027,7 +1027,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1062,7 +1062,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1160,7 +1160,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1253,7 +1253,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1351,7 +1351,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1444,7 +1444,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index 642eb04fd..a3ac7af27 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -93,6 +93,23 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { return __value } + @discardableResult + open func login(ssoToken: String) async throws -> Core.User { + addInvocation(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) + let perform = methodPerformValue(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) as? (String) -> Void + perform?(`ssoToken`) + var __value: User + do { + __value = try methodReturnValue(.m_login__SSO__username_password(Parameter.value(ssoToken))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for login(username: String, password: String). Use given") + Failure("Stub return value not specified for login(username: String, password: String). Use given") + } catch { + throw error + } + return __value + } + open func resetPassword(email: String) throws -> ResetPassword { addInvocation(.m_resetPassword__email_email(Parameter.value(`email`))) let perform = methodPerformValue(.m_resetPassword__email_email(Parameter.value(`email`))) as? (String) -> Void @@ -173,6 +190,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { fileprivate enum MethodType { case m_login__username_usernamepassword_password(Parameter, Parameter) + case m_login__SSO__username_password(Parameter) case m_login__externalToken_externalTokenbackend_backend(Parameter, Parameter) case m_resetPassword__email_email(Parameter) case m_getCookies__force_force(Parameter) @@ -223,6 +241,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func intValue() -> Int { switch self { case let .m_login__username_usernamepassword_password(p0, p1): return p0.intValue + p1.intValue + case let .m_login__SSO__username_password(p0): return p0.intValue case let .m_login__externalToken_externalTokenbackend_backend(p0, p1): return p0.intValue + p1.intValue case let .m_resetPassword__email_email(p0): return p0.intValue case let .m_getCookies__force_force(p0): return p0.intValue @@ -234,6 +253,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func assertionName() -> String { switch self { case .m_login__username_usernamepassword_password: return ".login(username:password:)" + case .m_login__SSO__username_password: return ".loginSSO(username:password:)" case .m_login__externalToken_externalTokenbackend_backend: return ".login(externalToken:backend:)" case .m_resetPassword__email_email: return ".resetPassword(email:)" case .m_getCookies__force_force: return ".getCookies(force:)" @@ -581,6 +601,12 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`title`, `url`) } + open func showSSOWebBrowser(title: String) { + addInvocation(.m_showWebBrowser__SSO(Parameter.value(`title`))) + let perform = methodPerformValue(.m_showWebBrowser__SSO(Parameter.value(`title`))) as? (String) -> Void + perform?(`title`) + } + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -619,6 +645,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showForgotPasswordScreen case m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(Parameter, Parameter) case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) + case m_showWebBrowser__SSO(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_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) @@ -732,6 +759,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return 0 case let .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(p0, p1): return p0.intValue + p1.intValue case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue + case let .m_showWebBrowser__SSO(p0): return p0.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_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue @@ -752,6 +780,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen: return ".showDiscoveryScreen(searchQuery:sourceScreen:)" case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" + case .m_showWebBrowser__SSO: return ".showSSOWebBrowser(title:)" 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_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" diff --git a/Discovery/Discovery.xcodeproj/project.pbxproj b/Discovery/Discovery.xcodeproj/project.pbxproj index 73f72e424..9ed79ac53 100644 --- a/Discovery/Discovery.xcodeproj/project.pbxproj +++ b/Discovery/Discovery.xcodeproj/project.pbxproj @@ -804,7 +804,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -919,7 +919,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1097,7 +1097,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; 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 = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1232,7 +1232,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1332,7 +1332,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1426,7 +1426,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1519,7 +1519,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; diff --git a/Discovery/Discovery/Info.plist b/Discovery/Discovery/Info.plist index f72a0f657..0c67376eb 100644 --- a/Discovery/Discovery/Info.plist +++ b/Discovery/Discovery/Info.plist @@ -1,12 +1,5 @@ - diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift index 78b37f590..d5bc80704 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift @@ -165,6 +165,13 @@ public struct CourseDetailsView: View { viewModel.courseDetails?.courseTitle ?? "" ) ) + case .signInWithSSO: + viewModel.router.showLoginScreen( + sourceScreen: .courseDetail( + courseID, + viewModel.courseDetails?.courseTitle ?? "" + ) + ) } } } diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift index d434dd05c..735abc4fb 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift @@ -146,7 +146,7 @@ public struct DiscoveryView: View { } } }.accessibilityAction {} - + if !viewModel.userloggedIn { LogistrationBottomView { buttonAction in switch buttonAction { @@ -154,18 +154,20 @@ public struct DiscoveryView: View { viewModel.router.showLoginScreen(sourceScreen: .discovery) case .register: viewModel.router.showRegisterScreen(sourceScreen: .discovery) + case .signInWithSSO: + viewModel.router.showLoginScreen(sourceScreen: .discovery) } } } }.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 { diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift index e4c23ff15..d67351713 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift @@ -14,7 +14,7 @@ public enum DiscoveryWebviewType: Equatable { case discovery case courseDetail(String) case programDetail(String) - + var rawValue: String { switch self { case .discovery: @@ -105,7 +105,7 @@ public struct DiscoveryWebview: View { webViewType: discoveryType.rawValue ) .accessibilityIdentifier("discovery_webview") - + if isLoading || viewModel.showProgress { HStack(alignment: .center) { ProgressBar( @@ -117,7 +117,7 @@ public struct DiscoveryWebview: View { } .frame(width: proxy.size.width, height: proxy.size.height) } - + // MARK: - Show Error if viewModel.showError { VStack { @@ -131,19 +131,21 @@ public struct DiscoveryWebview: View { } } } - + if !viewModel.userloggedIn, !isLoading { LogistrationBottomView { buttonAction in switch buttonAction { case .signIn: viewModel.router.showLoginScreen(sourceScreen: sourceScreen) + case .signInWithSSO: + viewModel.router.showLoginScreen(sourceScreen: sourceScreen) case .register: viewModel.router.showRegisterScreen(sourceScreen: sourceScreen) } } } } - + if viewModel.webViewError { FullScreenErrorView( type: viewModel.connectivity.isInternetAvaliable ? .generic : .noInternetWithReload diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift index b9f2bb515..df2330abf 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift @@ -15,7 +15,7 @@ public class DiscoveryWebviewViewModel: ObservableObject { @Published private(set) var showProgress = false @Published var showError: Bool = false @Published var webViewError: Bool = false - + var errorMessage: String? { didSet { withAnimation { @@ -137,25 +137,14 @@ 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) - self?.analytics.externalLinkOpenAction( - url: url.absoluteString, - screen: self?.sourceScreen.value ?? "", - action: "cancel" - ) - }, okTapped: { [weak self] in + }, okTapped: { 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 @@ -249,7 +238,7 @@ extension DiscoveryWebviewViewModel: WebViewNavigationDelegate { private func isValidAppURLScheme(_ url: URL) -> Bool { return url.scheme ?? "" == config.URIScheme } - + public func showWebViewError() { self.webViewError = true } diff --git a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index 93a07e4e6..54425fc98 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -93,6 +93,23 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { return __value } + @discardableResult + open func login(ssoToken: String) async throws -> Core.User { + addInvocation(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) + let perform = methodPerformValue(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) as? (String) -> Void + perform?(`ssoToken`) + var __value: User + do { + __value = try methodReturnValue(.m_login__SSO__username_password(Parameter.value(ssoToken))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for login(username: String, password: String). Use given") + Failure("Stub return value not specified for login(username: String, password: String). Use given") + } catch { + throw error + } + return __value + } + open func resetPassword(email: String) throws -> ResetPassword { addInvocation(.m_resetPassword__email_email(Parameter.value(`email`))) let perform = methodPerformValue(.m_resetPassword__email_email(Parameter.value(`email`))) as? (String) -> Void @@ -173,6 +190,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { fileprivate enum MethodType { case m_login__username_usernamepassword_password(Parameter, Parameter) + case m_login__SSO__username_password(Parameter) case m_login__externalToken_externalTokenbackend_backend(Parameter, Parameter) case m_resetPassword__email_email(Parameter) case m_getCookies__force_force(Parameter) @@ -188,6 +206,11 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsPassword, rhs: rhsPassword, with: matcher), lhsPassword, rhsPassword, "password")) return Matcher.ComparisonResult(results) + case (.m_login__SSO__username_password(let lhsJwtToken), .m_login__SSO__username_password(let rhsJwtToken)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsJwtToken, rhs: rhsJwtToken, with: matcher), lhsJwtToken, rhsJwtToken, "jwtToken")) + return Matcher.ComparisonResult(results) + case (.m_login__externalToken_externalTokenbackend_backend(let lhsExternaltoken, let lhsBackend), .m_login__externalToken_externalTokenbackend_backend(let rhsExternaltoken, let rhsBackend)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsExternaltoken, rhs: rhsExternaltoken, with: matcher), lhsExternaltoken, rhsExternaltoken, "externalToken")) @@ -223,6 +246,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func intValue() -> Int { switch self { case let .m_login__username_usernamepassword_password(p0, p1): return p0.intValue + p1.intValue + case let .m_login__SSO__username_password(p0): return p0.intValue case let .m_login__externalToken_externalTokenbackend_backend(p0, p1): return p0.intValue + p1.intValue case let .m_resetPassword__email_email(p0): return p0.intValue case let .m_getCookies__force_force(p0): return p0.intValue @@ -234,6 +258,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func assertionName() -> String { switch self { case .m_login__username_usernamepassword_password: return ".login(username:password:)" + case .m_login__SSO__username_password: return ".loginSSO(username:password:)" case .m_login__externalToken_externalTokenbackend_backend: return ".login(externalToken:backend:)" case .m_resetPassword__email_email: return ".resetPassword(email:)" case .m_getCookies__force_force: return ".getCookies(force:)" @@ -261,6 +286,10 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func login(externalToken: Parameter, backend: Parameter, willReturn: User...) -> MethodStub { return Given(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + @discardableResult + public static func ssoLogin(title: Parameter, willReturn: User...) -> MethodStub { + return Given(method: .m_login__SSO__username_password(`title`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func resetPassword(email: Parameter, willReturn: ResetPassword...) -> MethodStub { return Given(method: .m_resetPassword__email_email(`email`), products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -353,7 +382,8 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { fileprivate var method: MethodType @discardableResult - public static func login(username: Parameter, password: Parameter) -> Verify { return Verify(method: .m_login__username_usernamepassword_password(`username`, `password`))} + public static func login(username: Parameter, password: Parameter) -> Verify { return Verify(method: .m_login__username_usernamepassword_password(`username`, `password`))} + public static func ssoLogin(title: Parameter) -> Verify { return Verify(method: .m_login__SSO__username_password(`title`))} @discardableResult public static func login(externalToken: Parameter, backend: Parameter) -> Verify { return Verify(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`))} public static func resetPassword(email: Parameter) -> Verify { return Verify(method: .m_resetPassword__email_email(`email`))} @@ -581,6 +611,12 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`title`, `url`) } + open func showSSOWebBrowser(title: String) { + addInvocation(.m_showWebBrowser__SSO(Parameter.value(`title`))) + let perform = methodPerformValue(.m_showWebBrowser__SSO(Parameter.value(`title`))) as? (String) -> Void + perform?(`title`) + } + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -619,6 +655,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showForgotPasswordScreen case m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(Parameter, Parameter) case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) + case m_showWebBrowser__SSO(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_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) @@ -732,6 +769,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return 0 case let .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(p0, p1): return p0.intValue + p1.intValue case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue + case let .m_showWebBrowser__SSO(p0): return p0.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_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue @@ -752,6 +790,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen: return ".showDiscoveryScreen(searchQuery:sourceScreen:)" case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" + case .m_showWebBrowser__SSO: return ".showSSOWebBrowser(title:)" 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_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index 758b1cbdf..e443a5af5 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -76,6 +76,23 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { return __value } + @discardableResult + open func login(ssoToken: String) async throws -> Core.User { + addInvocation(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) + let perform = methodPerformValue(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) as? (String) -> Void + perform?(`ssoToken`) + var __value: User + do { + __value = try methodReturnValue(.m_login__SSO__username_password(Parameter.value(ssoToken))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for login(username: String, password: String). Use given") + Failure("Stub return value not specified for login(username: String, password: String). Use given") + } catch { + throw error + } + return __value + } + @discardableResult open func login(externalToken: String, backend: String) throws -> User { addInvocation(.m_login__externalToken_externalTokenbackend_backend(Parameter.value(`externalToken`), Parameter.value(`backend`))) @@ -173,6 +190,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { fileprivate enum MethodType { case m_login__username_usernamepassword_password(Parameter, Parameter) + case m_login__SSO__username_password(Parameter) case m_login__externalToken_externalTokenbackend_backend(Parameter, Parameter) case m_resetPassword__email_email(Parameter) case m_getCookies__force_force(Parameter) @@ -223,6 +241,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func intValue() -> Int { switch self { case let .m_login__username_usernamepassword_password(p0, p1): return p0.intValue + p1.intValue + case let .m_login__SSO__username_password(p0): return p0.intValue case let .m_login__externalToken_externalTokenbackend_backend(p0, p1): return p0.intValue + p1.intValue case let .m_resetPassword__email_email(p0): return p0.intValue case let .m_getCookies__force_force(p0): return p0.intValue @@ -234,6 +253,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func assertionName() -> String { switch self { case .m_login__username_usernamepassword_password: return ".login(username:password:)" + case .m_login__SSO__username_password: return ".loginSSO(username:password:)" case .m_login__externalToken_externalTokenbackend_backend: return ".login(externalToken:backend:)" case .m_resetPassword__email_email: return ".resetPassword(email:)" case .m_getCookies__force_force: return ".getCookies(force:)" @@ -581,6 +601,12 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`title`, `url`) } + open func showSSOWebBrowser(title: String) { + addInvocation(.m_showWebBrowser__SSO(Parameter.value(`title`))) + let perform = methodPerformValue(.m_showWebBrowser__SSO(Parameter.value(`title`))) as? (String) -> Void + perform?(`title`) + } + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -619,6 +645,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showForgotPasswordScreen case m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(Parameter, Parameter) case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) + case m_showWebBrowser__SSO(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_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) @@ -732,6 +759,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return 0 case let .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(p0, p1): return p0.intValue + p1.intValue case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue + case let .m_showWebBrowser__SSO(p0): return p0.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_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue @@ -752,6 +780,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen: return ".showDiscoveryScreen(searchQuery:sourceScreen:)" case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" + case .m_showWebBrowser__SSO: return ".showSSOWebBrowser(title:)" 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_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" @@ -3309,6 +3338,12 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { perform?(`title`, `url`) } + open func showSSOWebBrowser(title: String) { + addInvocation(.m_showWebBrowser__SSO(Parameter.value(`title`))) + let perform = methodPerformValue(.m_showWebBrowser__SSO(Parameter.value(`title`))) as? (String) -> Void + perform?(`title`) + } + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -3353,6 +3388,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { case m_showForgotPasswordScreen case m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(Parameter, Parameter) case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) + case m_showWebBrowser__SSO(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_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) @@ -3517,6 +3553,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { case .m_showForgotPasswordScreen: return 0 case let .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(p0, p1): return p0.intValue + p1.intValue case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue + case let .m_showWebBrowser__SSO(p0): return p0.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_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue @@ -3543,6 +3580,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen: return ".showDiscoveryScreen(searchQuery:sourceScreen:)" case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" + case .m_showWebBrowser__SSO: return ".showSSOWebBrowser(title:)" 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_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 68414c4d2..0d103570a 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -758,13 +758,14 @@ CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Allow access to your photo library so you can save photos in your gallery."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; @@ -849,13 +850,14 @@ CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Allow access to your photo library so you can save photos in your gallery."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; @@ -946,13 +948,14 @@ CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Allow access to your photo library so you can save photos in your gallery."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; @@ -1037,13 +1040,14 @@ CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Allow access to your photo library so you can save photos in your gallery."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; @@ -1188,13 +1192,14 @@ CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Allow access to your photo library so you can save photos in your gallery."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; @@ -1225,13 +1230,14 @@ CODE_SIGN_ENTITLEMENTS = OpenEdX/OpenEdX.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; FULLSTORY_ENABLED = NO; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = OpenEdX/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = ""; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.education"; INFOPLIST_KEY_NSCalendarsUsageDescription = "We would like to utilize your calendar list to subscribe you to your personalized calendar for this course."; + INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "Allow access to your photo library so you can save photos in your gallery."; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchStoryboardName = LaunchScreen; INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleLightContent; diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 5c4639e15..0ab06b29a 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -168,6 +168,12 @@ class AppAssembly: Assembly { r.resolve(AppStorage.self)! }.inObjectScope(.container) + container.register(SSOHelper.self){ r in + SSOHelper( + keychain: r.resolve(KeychainSwift.self)! + ) + } + container.register(Validator.self) { _ in Validator() }.inObjectScope(.container) diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index a65f25833..4ef5e7321 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -87,6 +87,15 @@ class ScreenAssembly: Assembly { sourceScreen: sourceScreen ) } + container.register(SSOWebViewModel.self) { r in + SSOWebViewModel( + interactor: r.resolve(AuthInteractorProtocol.self)!, + router: r.resolve(AuthorizationRouter.self)!, + config: r.resolve(ConfigProtocol.self)!, + analytics: r.resolve(AuthorizationAnalytics.self)!, + ssoHelper: r.resolve(SSOHelper.self)! + ) + } container.register(SignUpViewModel.self) { r, sourceScreen in SignUpViewModel( interactor: r.resolve(AuthInteractorProtocol.self)!, diff --git a/OpenEdX/Info.plist b/OpenEdX/Info.plist index 95202c02b..e9bd32e58 100644 --- a/OpenEdX/Info.plist +++ b/OpenEdX/Info.plist @@ -45,11 +45,5 @@ 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. - NSPhotoLibraryAddUsageDescription - Allow access to your photo library so you can save photos in your gallery. diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index b90f405d9..0004ca0cf 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -848,6 +848,16 @@ public class Router: AuthorizationRouter, let controller = UIHostingController(rootView: webBrowser) navigationController.pushViewController(controller, animated: true) } + + public func showSSOWebBrowser(title: String) { + let config = Container.shared.resolve(ConfigProtocol.self)! + let webBrowser = ContainerWebView( + config.baseSSOURL.absoluteString, + title: title + ) + let controller = UIHostingController(rootView: webBrowser) + navigationController.pushViewController(controller, animated: true) + } } // MARK: BackNavigationProtocol diff --git a/Profile/Profile.xcodeproj/project.pbxproj b/Profile/Profile.xcodeproj/project.pbxproj index ae075f652..1562e19f0 100644 --- a/Profile/Profile.xcodeproj/project.pbxproj +++ b/Profile/Profile.xcodeproj/project.pbxproj @@ -864,7 +864,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -899,7 +899,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -996,7 +996,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1094,7 +1094,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1186,7 +1186,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1277,7 +1277,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1500,7 +1500,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1613,7 +1613,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index ec514f18d..6d51643fe 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -62,18 +62,35 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { @discardableResult open func login(username: String, password: String) throws -> User { addInvocation(.m_login__username_usernamepassword_password(Parameter.value(`username`), Parameter.value(`password`))) - let perform = methodPerformValue(.m_login__username_usernamepassword_password(Parameter.value(`username`), Parameter.value(`password`))) as? (String, String) -> Void - perform?(`username`, `password`) - var __value: User - do { - __value = try methodReturnValue(.m_login__username_usernamepassword_password(Parameter.value(`username`), Parameter.value(`password`))).casted() - } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for login(username: String, password: String). Use given") - Failure("Stub return value not specified for login(username: String, password: String). Use given") - } catch { - throw error - } - return __value + let perform = methodPerformValue(.m_login__username_usernamepassword_password(Parameter.value(`username`), Parameter.value(`password`))) as? (String, String) -> Void + perform?(`username`, `password`) + var __value: User + do { + __value = try methodReturnValue(.m_login__username_usernamepassword_password(Parameter.value(`username`), Parameter.value(`password`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for login(username: String, password: String). Use given") + Failure("Stub return value not specified for login(username: String, password: String). Use given") + } catch { + throw error + } + return __value + } + + @discardableResult + open func login(ssoToken: String) async throws -> Core.User { + addInvocation(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) + let perform = methodPerformValue(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) as? (String) -> Void + perform?(`ssoToken`) + var __value: User + do { + __value = try methodReturnValue(.m_login__SSO__username_password(Parameter.value(ssoToken))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for login(username: String, password: String). Use given") + Failure("Stub return value not specified for login(username: String, password: String). Use given") + } catch { + throw error + } + return __value } @discardableResult @@ -173,6 +190,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { fileprivate enum MethodType { case m_login__username_usernamepassword_password(Parameter, Parameter) + case m_login__SSO__username_password(Parameter) case m_login__externalToken_externalTokenbackend_backend(Parameter, Parameter) case m_resetPassword__email_email(Parameter) case m_getCookies__force_force(Parameter) @@ -223,6 +241,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func intValue() -> Int { switch self { case let .m_login__username_usernamepassword_password(p0, p1): return p0.intValue + p1.intValue + case let .m_login__SSO__username_password(p0): return p0.intValue case let .m_login__externalToken_externalTokenbackend_backend(p0, p1): return p0.intValue + p1.intValue case let .m_resetPassword__email_email(p0): return p0.intValue case let .m_getCookies__force_force(p0): return p0.intValue @@ -234,6 +253,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func assertionName() -> String { switch self { case .m_login__username_usernamepassword_password: return ".login(username:password:)" + case .m_login__SSO__username_password: return ".loginSSO(username:password:)" case .m_login__externalToken_externalTokenbackend_backend: return ".login(externalToken:backend:)" case .m_resetPassword__email_email: return ".resetPassword(email:)" case .m_getCookies__force_force: return ".getCookies(force:)" @@ -581,6 +601,12 @@ open class BaseRouterMock: BaseRouter, Mock { perform?(`title`, `url`) } + open func showSSOWebBrowser(title: String) { + addInvocation(.m_showWebBrowser__SSO(Parameter.value(`title`))) + let perform = methodPerformValue(.m_showWebBrowser__SSO(Parameter.value(`title`))) as? (String) -> Void + perform?(`title`) + } + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -619,6 +645,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showForgotPasswordScreen case m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(Parameter, Parameter) case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) + case m_showWebBrowser__SSO(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_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) @@ -732,6 +759,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return 0 case let .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(p0, p1): return p0.intValue + p1.intValue case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue + case let .m_showWebBrowser__SSO(p0): return p0.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_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue @@ -752,6 +780,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen: return ".showDiscoveryScreen(searchQuery:sourceScreen:)" case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" + case .m_showWebBrowser__SSO: return ".showSSOWebBrowser(title:)" 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_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" @@ -4194,6 +4223,12 @@ open class ProfileRouterMock: ProfileRouter, Mock { perform?(`title`, `url`) } + open func showSSOWebBrowser(title: String) { + addInvocation(.m_showWebBrowser__SSO(Parameter.value(`title`))) + let perform = methodPerformValue(.m_showWebBrowser__SSO(Parameter.value(`title`))) as? (String) -> Void + perform?(`title`) + } + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -4242,6 +4277,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case m_showForgotPasswordScreen case m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(Parameter, Parameter) case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) + case m_showWebBrowser__SSO(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_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) @@ -4398,6 +4434,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case .m_showForgotPasswordScreen: return 0 case let .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(p0, p1): return p0.intValue + p1.intValue case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue + case let .m_showWebBrowser__SSO(p0): return p0.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_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue @@ -4428,6 +4465,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen: return ".showDiscoveryScreen(searchQuery:sourceScreen:)" case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" + case .m_showWebBrowser__SSO: return ".showSSOWebBrowser(title:)" 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_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" diff --git a/Theme/Theme.xcodeproj/project.pbxproj b/Theme/Theme.xcodeproj/project.pbxproj index ea888a71a..6178a2a2a 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 = ""; 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 = ""; 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 = ""; 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 = ""; 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 = ""; 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 = ""; 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 = ""; 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 = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; diff --git a/WhatsNew/WhatsNew.xcodeproj/project.pbxproj b/WhatsNew/WhatsNew.xcodeproj/project.pbxproj index 2cbc37bda..49ad542c3 100644 --- a/WhatsNew/WhatsNew.xcodeproj/project.pbxproj +++ b/WhatsNew/WhatsNew.xcodeproj/project.pbxproj @@ -612,7 +612,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -651,7 +651,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -797,7 +797,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -923,7 +923,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1049,7 +1049,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; 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 = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1286,7 +1286,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; @@ -1404,7 +1404,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEFINES_MODULE = YES; - DEVELOPMENT_TEAM = L8PG7LC3Y3; + DEVELOPMENT_TEAM = ""; DYLIB_COMPATIBILITY_VERSION = 1; DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; diff --git a/WhatsNew/WhatsNew/Info.plist b/WhatsNew/WhatsNew/Info.plist index f72a0f657..0c67376eb 100644 --- a/WhatsNew/WhatsNew/Info.plist +++ b/WhatsNew/WhatsNew/Info.plist @@ -1,12 +1,5 @@ - diff --git a/default_config/dev/config.yaml b/default_config/dev/config.yaml index d7cec08d1..ffd91e5dc 100644 --- a/default_config/dev/config.yaml +++ b/default_config/dev/config.yaml @@ -1,8 +1,19 @@ API_HOST_URL: 'http://localhost:8000' +SSO_URL: 'http://localhost:8000' +SSO_FINISHED_URL: 'http://localhost:8000' ENVIRONMENT_DISPLAY_NAME: 'Localhost' FEEDBACK_EMAIL_ADDRESS: 'support@example.com' OAUTH_CLIENT_ID: '' +SSO_BUTTON_TITLE: + ar: "الدخول عبر SSO" + en: "Sign in with SSO" + + + UI_COMPONENTS: COURSE_UNIT_PROGRESS_ENABLED: false COURSE_NESTED_LIST_ENABLED: false + LOGIN_REGISTRATION_ENABLED: true + SAML_SSO_LOGIN_ENABLED: false + SAML_SSO_DEFAULT_LOGIN_BUTTON: false diff --git a/default_config/prod/config.yaml b/default_config/prod/config.yaml index d7cec08d1..0edb91b80 100644 --- a/default_config/prod/config.yaml +++ b/default_config/prod/config.yaml @@ -1,8 +1,18 @@ API_HOST_URL: 'http://localhost:8000' +SSO_URL: 'http://localhost:8000' +SSO_FINISHED_URL: 'http://localhost:8000' ENVIRONMENT_DISPLAY_NAME: 'Localhost' FEEDBACK_EMAIL_ADDRESS: 'support@example.com' OAUTH_CLIENT_ID: '' +SSO_BUTTON_TITLE: + ar: "الدخول عبر SSO" + en: "Sign in with SSO" + + UI_COMPONENTS: COURSE_UNIT_PROGRESS_ENABLED: false COURSE_NESTED_LIST_ENABLED: false + LOGIN_REGISTRATION_ENABLED: true + SAML_SSO_LOGIN_ENABLED: false + SAML_SSO_DEFAULT_LOGIN_BUTTON: false diff --git a/default_config/stage/config.yaml b/default_config/stage/config.yaml index d7cec08d1..0edb91b80 100644 --- a/default_config/stage/config.yaml +++ b/default_config/stage/config.yaml @@ -1,8 +1,18 @@ API_HOST_URL: 'http://localhost:8000' +SSO_URL: 'http://localhost:8000' +SSO_FINISHED_URL: 'http://localhost:8000' ENVIRONMENT_DISPLAY_NAME: 'Localhost' FEEDBACK_EMAIL_ADDRESS: 'support@example.com' OAUTH_CLIENT_ID: '' +SSO_BUTTON_TITLE: + ar: "الدخول عبر SSO" + en: "Sign in with SSO" + + UI_COMPONENTS: COURSE_UNIT_PROGRESS_ENABLED: false COURSE_NESTED_LIST_ENABLED: false + LOGIN_REGISTRATION_ENABLED: true + SAML_SSO_LOGIN_ENABLED: false + SAML_SSO_DEFAULT_LOGIN_BUTTON: false From d7ad72a252e1721031f89ebb0a4303497cceeba4 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Mon, 28 Oct 2024 14:51:24 +0100 Subject: [PATCH 46/55] fix: Part 2 sync to upstream (#530) * Merge pull request #29 from edx/Shafqat/LEARNER-10022-Accessibility chore: Fix Initial Accessibility Testing Issues * chore: auto-generated string * fix: snack bar and resume course button color fix for light dark modes (#34) * Merge pull request #31 from edx/about-blank-links-fix fix: open link in Safari if link has target blank * Merge pull request #36 from edx/Shafqat/AllCoursesFilterFix Fix All Courses Taping Issue --------- Co-authored-by: Anton Yarmolenko <37253+rnr@users.noreply.github.com> Co-authored-by: Saeed Bashir Co-authored-by: Vadim Kuznetsov --- Core/Core/SwiftGen/Strings.swift | 8 ++++++- .../Core/View/Base/BackNavigationButton.swift | 2 ++ Core/Core/View/Base/OfflineSnackBarView.swift | 16 +++++++++----- Core/Core/View/Base/UnitButtonView.swift | 6 +++++- Core/Core/View/Base/Webview/WebView.swift | 21 +++++++++++-------- Core/Core/en.lproj/Localizable.strings | 5 ++++- .../Data/Network/DashboardEndpoint.swift | 3 ++- .../Presentation/Settings/SettingsView.swift | 8 +++---- Profile/Profile/SwiftGen/Strings.swift | 2 ++ Profile/Profile/en.lproj/Localizable.strings | 1 + 10 files changed, 50 insertions(+), 22 deletions(-) diff --git a/Core/Core/SwiftGen/Strings.swift b/Core/Core/SwiftGen/Strings.swift index e42e1dd03..672300224 100644 --- a/Core/Core/SwiftGen/Strings.swift +++ b/Core/Core/SwiftGen/Strings.swift @@ -10,8 +10,14 @@ import Foundation // swiftlint:disable explicit_type_interface function_parameter_count identifier_name line_length // swiftlint:disable nesting type_body_length type_name vertical_whitespace_opening_braces public enum CoreLocalization { + /// Back + public static let back = CoreLocalization.tr("Localizable", "BACK", fallback: "Back") + /// Close + public static let close = CoreLocalization.tr("Localizable", "CLOSE", fallback: "Close") /// Done public static let done = CoreLocalization.tr("Localizable", "DONE", fallback: "Done") + /// Ok + public static let ok = CoreLocalization.tr("Localizable", "OK", fallback: "Ok") /// View in Safari public static let openInBrowser = CoreLocalization.tr("Localizable", "OPEN_IN_BROWSER", fallback: "View in Safari") /// The user canceled the sign-in flow. @@ -19,7 +25,7 @@ public enum CoreLocalization { /// Tomorrow public static let tomorrow = CoreLocalization.tr("Localizable", "TOMORROW", fallback: "Tomorrow") /// View - public static let view = CoreLocalization.tr("Localizable", "VIEW ", fallback: "View") + public static let view = CoreLocalization.tr("Localizable", "VIEW", fallback: "View") /// Yesterday public static let yesterday = CoreLocalization.tr("Localizable", "YESTERDAY", fallback: "Yesterday") public enum Alert { diff --git a/Core/Core/View/Base/BackNavigationButton.swift b/Core/Core/View/Base/BackNavigationButton.swift index 001f5d340..415433cd1 100644 --- a/Core/Core/View/Base/BackNavigationButton.swift +++ b/Core/Core/View/Base/BackNavigationButton.swift @@ -78,6 +78,8 @@ public struct BackNavigationButton: View { public var body: some View { BackNavigationButtonRepresentable(action: action, color: color, viewModel: viewModel) + .accessibilityIdentifier("back_button") + .accessibilityLabel(CoreLocalization.back) .onAppear { viewModel.loadItems() } diff --git a/Core/Core/View/Base/OfflineSnackBarView.swift b/Core/Core/View/Base/OfflineSnackBarView.swift index 4928c6936..af1c9e778 100644 --- a/Core/Core/View/Base/OfflineSnackBarView.swift +++ b/Core/Core/View/Base/OfflineSnackBarView.swift @@ -29,23 +29,29 @@ public struct OfflineSnackBarView: View { HStack(spacing: 12) { Text(CoreLocalization.NoInternet.offline) .accessibilityIdentifier("no_internet_text") + .foregroundColor(Theme.Colors.snackbarTextColor) Spacer() - Button(CoreLocalization.NoInternet.dismiss, - action: { + Button(action: { withAnimation { dismiss = true } + }, label: { + Text(CoreLocalization.NoInternet.dismiss) + .foregroundColor(Theme.Colors.snackbarTextColor) }) .accessibilityIdentifier("no_internet_dismiss_button") - Button(CoreLocalization.NoInternet.reload, - action: { + Button(action: { Task { await reloadAction() } withAnimation { dismiss = true } - }) + }, label: { + Text(CoreLocalization.NoInternet.reload) + .foregroundColor(Theme.Colors.snackbarTextColor) + } + ) .accessibilityIdentifier("no_internet_reload_button") }.padding(.horizontal, 16) .font(Theme.Fonts.titleSmall) diff --git a/Core/Core/View/Base/UnitButtonView.swift b/Core/Core/View/Base/UnitButtonView.swift index 6778bb8dd..37c94eeb4 100644 --- a/Core/Core/View/Base/UnitButtonView.swift +++ b/Core/Core/View/Base/UnitButtonView.swift @@ -149,7 +149,11 @@ public struct UnitButtonView: View { .padding(.leading, 20) .font(Theme.Fonts.labelLarge) CoreAssets.arrowLeft.swiftUIImage.renderingMode(.template) - .foregroundColor(Theme.Colors.styledButtonText) + .foregroundColor( + type == .continueLesson + ? Theme.Colors.accentColor + : Theme.Colors.styledButtonText + ) .rotationEffect(Angle.degrees(180)) .padding(.trailing, 20) } diff --git a/Core/Core/View/Base/Webview/WebView.swift b/Core/Core/View/Base/Webview/WebView.swift index 7ed3099a6..c5b21e7ef 100644 --- a/Core/Core/View/Base/Webview/WebView.swift +++ b/Core/Core/View/Base/Webview/WebView.swift @@ -177,20 +177,23 @@ public struct WebView: UIViewRepresentable { } let baseURL = await parent.viewModel.baseURL - if !baseURL.isEmpty, !url.absoluteString.starts(with: baseURL) { - if navigationAction.navigationType == .other { - return .allow - } else if navigationAction.navigationType == .linkActivated { - await MainActor.run { + switch navigationAction.navigationType { + case .other, .formSubmitted, .formResubmitted: + return .allow + case .linkActivated: + await MainActor.run { + if UIApplication.shared.canOpenURL(url) { UIApplication.shared.open(url, options: [:]) } - } else if navigationAction.navigationType == .formSubmitted { - return .allow } return .cancel + default: + if !baseURL.isEmpty, !url.absoluteString.starts(with: baseURL) { + return .cancel + } else { + return .allow + } } - - return .allow } public func webView( diff --git a/Core/Core/en.lproj/Localizable.strings b/Core/Core/en.lproj/Localizable.strings index 55f7e55dd..97d00ae26 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -92,7 +92,10 @@ "SETTINGS.DOWNLOAD_QUALITY_720_DESCRIPTION" = "Best quality"; "DONE" = "Done"; -"VIEW " = "View"; +"VIEW" = "View"; +"BACK" = "Back"; +"OK" = "Ok"; +"CLOSE" = "Close"; "PICKER.SEARCH" = "Search"; "PICKER.ACCEPT" = "Accept"; diff --git a/Dashboard/Dashboard/Data/Network/DashboardEndpoint.swift b/Dashboard/Dashboard/Data/Network/DashboardEndpoint.swift index cda65b250..59f379215 100644 --- a/Dashboard/Dashboard/Data/Network/DashboardEndpoint.swift +++ b/Dashboard/Dashboard/Data/Network/DashboardEndpoint.swift @@ -51,8 +51,9 @@ enum DashboardEndpoint: EndPointType { return .requestParameters(parameters: params, encoding: URLEncoding.queryString) case let .getAllCourses(_, filteredBy, page): + var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } let params: Parameters = [ - "page_size": 10, + "page_size": idiom == .pad ? 24 : 12, "status": filteredBy, "requested_fields": "course_progress", "page": page diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index cc1eb0e45..2d1ee52d9 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -29,7 +29,7 @@ public struct SettingsView: View { .resizable() .edgesIgnoringSafeArea(.top) } - .frame(maxWidth: .infinity, maxHeight: 200) + .frame(maxWidth: .infinity, maxHeight: 50) .accessibilityIdentifier("auth_bg_image") // MARK: - Page name @@ -120,7 +120,7 @@ public struct SettingsView: View { viewModel.router.showDatesAndCalendar() }, label: { HStack { - Text("Dates & Calendar") // TODO: add ProfileLocalization... + Text(ProfileLocalization.datesAndCalendar) .font(Theme.Fonts.titleMedium) Spacer() Image(systemName: "chevron.right") @@ -130,7 +130,7 @@ public struct SettingsView: View { } .accessibilityElement(children: .ignore) - .accessibilityLabel(ProfileLocalization.settingsVideo) + .accessibilityLabel(ProfileLocalization.datesAndCalendar) .cardStyle( bgColor: Theme.Colors.textInputUnfocusedBackground, strokeColor: .clear @@ -156,7 +156,7 @@ public struct SettingsView: View { .accessibilityIdentifier("video_settings_button") } .accessibilityElement(children: .ignore) - .accessibilityLabel(ProfileLocalization.settingsVideo) + .accessibilityLabel(ProfileLocalization.manageAccount) .cardStyle( bgColor: Theme.Colors.textInputUnfocusedBackground, strokeColor: .clear diff --git a/Profile/Profile/SwiftGen/Strings.swift b/Profile/Profile/SwiftGen/Strings.swift index 0963ce3b5..3401ceb79 100644 --- a/Profile/Profile/SwiftGen/Strings.swift +++ b/Profile/Profile/SwiftGen/Strings.swift @@ -18,6 +18,8 @@ public enum ProfileLocalization { public static let contact = ProfileLocalization.tr("Localizable", "CONTACT", fallback: "Contact support") /// Cookie policy public static let cookiePolicy = ProfileLocalization.tr("Localizable", "COOKIE_POLICY", fallback: "Cookie policy") + /// Dates & Calendar + public static let datesAndCalendar = ProfileLocalization.tr("Localizable", "DATES_AND_CALENDAR", fallback: "Dates & Calendar") /// Do not sell my personal information public static let doNotSellInformation = ProfileLocalization.tr("Localizable", "DO_NOT_SELL_INFORMATION", fallback: "Do not sell my personal information") /// Edit Profile diff --git a/Profile/Profile/en.lproj/Localizable.strings b/Profile/Profile/en.lproj/Localizable.strings index 42a2871fb..8a91753ee 100644 --- a/Profile/Profile/en.lproj/Localizable.strings +++ b/Profile/Profile/en.lproj/Localizable.strings @@ -14,6 +14,7 @@ "BIO" = "Bio:"; "SETTINGS" = "Settings"; "SETTINGS_VIDEO" = "Video settings"; +"DATES_AND_CALENDAR" = "Dates & Calendar"; "SUPPORT_INFO" = "Support info"; "CONTACT" = "Contact support"; "TERMS" = "Terms of use"; From 8cfe33fe050c1355c3579b1399cff88cb00775c4 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Tue, 29 Oct 2024 08:07:06 +0200 Subject: [PATCH 47/55] fix: change EnrollmentsStatus value isActive to recentlyActive (#529) --- Core/Core/Configuration/Connectivity.swift | 1 + .../Data/Model/Data_EnrollmentsStatus.swift | 8 ++++---- Core/Core/Domain/Model/CourseForSync.swift | 10 +++++----- Core/Core/Extensions/Container+App.swift | 1 + Core/Core/Network/Alamofire+Error.swift | 1 + Core/Core/Network/NetworkLogger.swift | 1 + OpenEdX/View/MainScreenViewModel.swift | 2 +- Podfile | 4 ++-- Podfile.lock | 15 ++++++++++----- Profile/Profile/Data/ProfileRepository.swift | 18 +++++++++--------- .../DatesAndCalendar/CalendarManager.swift | 6 +++--- .../DatesAndCalendar/CoursesToSyncView.swift | 8 ++++---- .../DatesAndCalendarViewModel.swift | 16 ++++++++-------- 13 files changed, 50 insertions(+), 41 deletions(-) diff --git a/Core/Core/Configuration/Connectivity.swift b/Core/Core/Configuration/Connectivity.swift index c1ea53d82..c69ee18b9 100644 --- a/Core/Core/Configuration/Connectivity.swift +++ b/Core/Core/Configuration/Connectivity.swift @@ -7,6 +7,7 @@ import Alamofire import Combine +import Foundation public enum InternetState { case reachable diff --git a/Core/Core/Data/Model/Data_EnrollmentsStatus.swift b/Core/Core/Data/Model/Data_EnrollmentsStatus.swift index df5acf0d2..7b2cc76fd 100644 --- a/Core/Core/Data/Model/Data_EnrollmentsStatus.swift +++ b/Core/Core/Data/Model/Data_EnrollmentsStatus.swift @@ -12,18 +12,18 @@ extension DataLayer { public struct EnrollmentsStatusElement: Codable { public let courseID: String? public let courseName: String? - public let isActive: Bool? + public let recentlyActive: Bool? public enum CodingKeys: String, CodingKey { case courseID = "course_id" case courseName = "course_name" - case isActive = "is_active" + case recentlyActive = "recently_active" } - public init(courseID: String?, courseName: String?, isActive: Bool?) { + public init(courseID: String?, courseName: String?, recentlyActive: Bool?) { self.courseID = courseID self.courseName = courseName - self.isActive = isActive + self.recentlyActive = recentlyActive } } diff --git a/Core/Core/Domain/Model/CourseForSync.swift b/Core/Core/Domain/Model/CourseForSync.swift index 938c6efa2..d3d7af491 100644 --- a/Core/Core/Domain/Model/CourseForSync.swift +++ b/Core/Core/Domain/Model/CourseForSync.swift @@ -13,14 +13,14 @@ public struct CourseForSync: Identifiable { public let courseID: String public let name: String public var synced: Bool - public var active: Bool + public var recentlyActive: Bool - public init(id: UUID = UUID(), courseID: String, name: String, synced: Bool, active: Bool) { + public init(id: UUID = UUID(), courseID: String, name: String, synced: Bool, recentlyActive: Bool) { self.id = id self.courseID = courseID self.name = name self.synced = synced - self.active = active + self.recentlyActive = recentlyActive } } @@ -29,13 +29,13 @@ extension DataLayer.EnrollmentsStatus { self.compactMap { guard let courseID = $0.courseID, let courseName = $0.courseName, - let isActive = $0.isActive else { return nil } + let recentlyActive = $0.recentlyActive else { return nil } return CourseForSync( id: UUID(), courseID: courseID, name: courseName, synced: false, - active: isActive + recentlyActive: recentlyActive ) } } diff --git a/Core/Core/Extensions/Container+App.swift b/Core/Core/Extensions/Container+App.swift index 01091f661..0362d708c 100644 --- a/Core/Core/Extensions/Container+App.swift +++ b/Core/Core/Extensions/Container+App.swift @@ -7,6 +7,7 @@ import Foundation import Swinject +import UIKit public extension Container { static var shared: Container = { diff --git a/Core/Core/Network/Alamofire+Error.swift b/Core/Core/Network/Alamofire+Error.swift index 277a79fed..90068b90a 100644 --- a/Core/Core/Network/Alamofire+Error.swift +++ b/Core/Core/Network/Alamofire+Error.swift @@ -6,6 +6,7 @@ // import Alamofire +import Foundation public extension Error { var isUpdateRequeiredError: Bool { diff --git a/Core/Core/Network/NetworkLogger.swift b/Core/Core/Network/NetworkLogger.swift index f41f1f103..7335be2b2 100644 --- a/Core/Core/Network/NetworkLogger.swift +++ b/Core/Core/Network/NetworkLogger.swift @@ -6,6 +6,7 @@ // import Alamofire +import Foundation public class NetworkLogger: EventMonitor { diff --git a/OpenEdX/View/MainScreenViewModel.swift b/OpenEdX/View/MainScreenViewModel.swift index 70b74ca6b..da496110c 100644 --- a/OpenEdX/View/MainScreenViewModel.swift +++ b/OpenEdX/View/MainScreenViewModel.swift @@ -150,7 +150,7 @@ extension MainScreenViewModel { } do { - var coursesForSync = try await profileInteractor.enrollmentsStatus().filter { $0.active } + var coursesForSync = try await profileInteractor.enrollmentsStatus().filter { $0.recentlyActive } let selectedCourses = await calendarManager.filterCoursesBySelected(fetchedCourses: coursesForSync) diff --git a/Podfile b/Podfile index 4ce194c65..33bbe344f 100644 --- a/Podfile +++ b/Podfile @@ -17,8 +17,8 @@ abstract_target "App" do project './Core/Core.xcodeproj' workspace './Core/Core.xcodeproj' #Networking - pod 'Alamofire', '~> 5.9' - #Keychain + pod 'Alamofire', :git => 'https://github.com/Alamofire/Alamofire.git', :tag => '5.10.0' +#Keychain pod 'KeychainSwift', '~> 24.0' #SwiftUI backward UIKit access #pod 'Introspect', '~> 0.6' diff --git a/Podfile.lock b/Podfile.lock index a79ae0c35..43f9058d6 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,5 +1,5 @@ PODS: - - Alamofire (5.9.1) + - Alamofire (5.10.0) - KeychainSwift (24.0.0) - Kingfisher (8.0.3) - Sourcery (1.8.0): @@ -13,7 +13,7 @@ PODS: - Swinject (2.9.1) DEPENDENCIES: - - Alamofire (~> 5.9) + - Alamofire (from `https://github.com/Alamofire/Alamofire.git`, tag `5.10.0`) - KeychainSwift (~> 24.0) - Kingfisher (~> 8.0) - SwiftGen (~> 6.6) @@ -24,7 +24,6 @@ DEPENDENCIES: SPEC REPOS: trunk: - - Alamofire - KeychainSwift - Kingfisher - Sourcery @@ -34,17 +33,23 @@ SPEC REPOS: - Swinject EXTERNAL SOURCES: + Alamofire: + :git: https://github.com/Alamofire/Alamofire.git + :tag: 5.10.0 SwiftyMocky: :git: https://github.com/MakeAWishFoundation/SwiftyMocky.git :tag: 4.2.0 CHECKOUT OPTIONS: + Alamofire: + :git: https://github.com/Alamofire/Alamofire.git + :tag: 5.10.0 SwiftyMocky: :git: https://github.com/MakeAWishFoundation/SwiftyMocky.git :tag: 4.2.0 SPEC CHECKSUMS: - Alamofire: f36a35757af4587d8e4f4bfa223ad10be2422b8c + Alamofire: cd0b98508df05796dd2ff278f3bb055a631b5390 KeychainSwift: 007c4647486e4563adca839cf02cef00deb3b670 Kingfisher: bbf78af014cc845cf9a799363f627b5212784165 Sourcery: 6f5fe49b82b7e02e8c65560cbd52e1be67a1af2e @@ -54,6 +59,6 @@ SPEC CHECKSUMS: SwiftyMocky: c5e96e4ff76ec6dbf5a5941aeb039b5a546954a0 Swinject: a827d508c6270da03ec74e558e728917a888fa9b -PODFILE CHECKSUM: b3183e95d2b3bf330c512113a847f9a5485c23a5 +PODFILE CHECKSUM: 1873e8ac2c331deb6c08b61ef4cd18d56e060c40 COCOAPODS: 1.15.2 diff --git a/Profile/Profile/Data/ProfileRepository.swift b/Profile/Profile/Data/ProfileRepository.swift index a593f7380..e8a20b9c2 100644 --- a/Profile/Profile/Data/ProfileRepository.swift +++ b/Profile/Profile/Data/ProfileRepository.swift @@ -257,15 +257,15 @@ class ProfileRepositoryMock: ProfileRepositoryProtocol { public func enrollmentsStatus() async throws -> [CourseForSync] { let result = [ - DataLayer.EnrollmentsStatusElement(courseID: "1", courseName: "Course 1", isActive: true), - DataLayer.EnrollmentsStatusElement(courseID: "2", courseName: "Course 2", isActive: false), - DataLayer.EnrollmentsStatusElement(courseID: "3", courseName: "Course 3", isActive: false), - DataLayer.EnrollmentsStatusElement(courseID: "4", courseName: "Course 4", isActive: true), - DataLayer.EnrollmentsStatusElement(courseID: "5", courseName: "Course 5", isActive: true), - DataLayer.EnrollmentsStatusElement(courseID: "6", courseName: "Course 6", isActive: false), - DataLayer.EnrollmentsStatusElement(courseID: "7", courseName: "Course 7", isActive: true), - DataLayer.EnrollmentsStatusElement(courseID: "8", courseName: "Course 8", isActive: true), - DataLayer.EnrollmentsStatusElement(courseID: "9", courseName: "Course 9", isActive: true), + DataLayer.EnrollmentsStatusElement(courseID: "1", courseName: "Course 1", recentlyActive: true), + DataLayer.EnrollmentsStatusElement(courseID: "2", courseName: "Course 2", recentlyActive: false), + DataLayer.EnrollmentsStatusElement(courseID: "3", courseName: "Course 3", recentlyActive: false), + DataLayer.EnrollmentsStatusElement(courseID: "4", courseName: "Course 4", recentlyActive: true), + DataLayer.EnrollmentsStatusElement(courseID: "5", courseName: "Course 5", recentlyActive: true), + DataLayer.EnrollmentsStatusElement(courseID: "6", courseName: "Course 6", recentlyActive: false), + DataLayer.EnrollmentsStatusElement(courseID: "7", courseName: "Course 7", recentlyActive: true), + DataLayer.EnrollmentsStatusElement(courseID: "8", courseName: "Course 8", recentlyActive: true), + DataLayer.EnrollmentsStatusElement(courseID: "9", courseName: "Course 9", recentlyActive: true), ] return result.domain diff --git a/Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift b/Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift index 8e4a7ad22..a66ef85fc 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift @@ -217,7 +217,7 @@ public class CalendarManager: CalendarManagerProtocol { !fetchedCourses.contains { $0.courseID == course.courseID } } let inactiveCourses = fetchedCourses.filter { course in - courseCalendarStates.contains { $0.courseID == course.courseID } && !course.active + courseCalendarStates.contains { $0.courseID == course.courseID } && !course.recentlyActive } for course in coursesToDelete { @@ -229,11 +229,11 @@ public class CalendarManager: CalendarManagerProtocol { } let newlyActiveCourses = fetchedCourses.filter { fetchedCourse in - courseCalendarStates.contains { $0.courseID == fetchedCourse.courseID } && fetchedCourse.active + courseCalendarStates.contains { $0.courseID == fetchedCourse.courseID } && fetchedCourse.recentlyActive } return fetchedCourses.filter { course in - courseCalendarStates.contains { $0.courseID == course.courseID } && course.active + courseCalendarStates.contains { $0.courseID == course.courseID } && course.recentlyActive } } else { return fetchedCourses diff --git a/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift b/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift index 54e0341fa..b5d0aa1a2 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/CoursesToSyncView.swift @@ -90,9 +90,9 @@ public struct CoursesToSyncView: View { ForEach( Array( viewModel.coursesForSync.filter({ course in - course.synced == viewModel.synced && (!viewModel.hideInactiveCourses || course.active) + course.synced == viewModel.synced && (!viewModel.hideInactiveCourses || course.recentlyActive) }) - .sorted { $0.active && !$1.active } + .sorted { $0.recentlyActive && !$1.recentlyActive } .enumerated() ), id: \.offset @@ -104,10 +104,10 @@ public struct CoursesToSyncView: View { set: { _ in viewModel.toggleSync(for: course) } ), text: course.name, - color: Theme.Colors.textPrimary.opacity(course.active ? 1 : 0.8) + color: Theme.Colors.textPrimary.opacity(course.recentlyActive ? 1 : 0.8) ) - if !course.active { + if !course.recentlyActive { Text(ProfileLocalization.CoursesToSync.inactive) .font(Theme.Fonts.labelSmall) .foregroundStyle(Theme.Colors.textPrimary.opacity(0.8)) diff --git a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift index 667546c24..66be4d491 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift @@ -214,7 +214,7 @@ public class DatesAndCalendarViewModel: ObservableObject { var updatedCourse = course updatedCourse.synced = courseCalendarStates.contains { $0.courseID == course.courseID - } && course.active + } && course.recentlyActive return updatedCourse } @@ -235,7 +235,7 @@ public class DatesAndCalendarViewModel: ObservableObject { courseID: course.courseID, courseName: course.name, courseDates: courseDates, - active: course.active + active: course.recentlyActive ) } catch { assignmentStatus = .failed @@ -253,7 +253,7 @@ public class DatesAndCalendarViewModel: ObservableObject { } private func updateCoursesCount() { - syncingCoursesCount = coursesForSync.filter { $0.active && $0.synced }.count + syncingCoursesCount = coursesForSync.filter { $0.recentlyActive && $0.synced }.count } @MainActor @@ -266,7 +266,7 @@ public class DatesAndCalendarViewModel: ObservableObject { return } let selectedCourses = await calendarManager.filterCoursesBySelected(fetchedCourses: coursesForSync) - let activeSelectedCourses = selectedCourses.filter { $0.active } + let activeSelectedCourses = selectedCourses.filter { $0.recentlyActive } assignmentStatus = .loading for course in activeSelectedCourses { do { @@ -275,7 +275,7 @@ public class DatesAndCalendarViewModel: ObservableObject { courseID: course.courseID, courseName: course.name, courseDates: courseDates, - active: course.active + active: course.recentlyActive ) } catch { assignmentStatus = .failed @@ -289,7 +289,7 @@ public class DatesAndCalendarViewModel: ObservableObject { } private func filterCoursesBySynced() -> [CourseForSync] { - let syncedCourses = coursesForSync.filter { $0.synced && $0.active } + let syncedCourses = coursesForSync.filter { $0.synced && $0.recentlyActive } return syncedCourses } @@ -328,7 +328,7 @@ public class DatesAndCalendarViewModel: ObservableObject { } await calendarManager.syncCourse(courseID: courseID, courseName: courseName, dates: courseDates) - if let index = self.coursesForSync.firstIndex(where: { $0.courseID == courseID && $0.active }) { + if let index = self.coursesForSync.firstIndex(where: { $0.courseID == courseID && $0.recentlyActive }) { await MainActor.run { self.coursesForSync[index].synced = true } @@ -354,7 +354,7 @@ public class DatesAndCalendarViewModel: ObservableObject { } func toggleSync(for course: CourseForSync) { - guard course.active else { return } + guard course.recentlyActive else { return } if coursesForSyncBeforeChanges.isEmpty { coursesForSyncBeforeChanges = coursesForSync } From c4fcc19fe705df752e24c26a28222e2df3ea54be Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Tue, 29 Oct 2024 18:35:56 +0200 Subject: [PATCH 48/55] test: add unit tests for DownloadManager (#532) * test: add DownloadManager tests * fix: delete old file * fix: add SwiftyMocky to DownloadManager tests * fix: address feedback * fix: updated mock files --- .swiftlint.yml | 1 + .../Authorization/SwiftGen/Strings.swift | 4 +- Core/Core.xcodeproj/project.pbxproj | 101 + Core/Core/Data/CoreStorage.swift | 1 + Core/Core/Network/DownloadManager.swift | 4 +- Core/CoreTests/CoreMock.generated.swift | 3641 +++++++++++++++++ .../DownloadManagerTests.swift | 328 ++ Core/Mockfile | 16 + Course/CourseTests/CourseMock.generated.swift | 492 ++- .../DashboardMock.generated.swift | 482 ++- .../DiscoveryMock.generated.swift | 492 ++- .../DiscussionMock.generated.swift | 507 ++- ...DiscussionSearchTopicsViewModelTests.swift | 5 +- .../Posts/PostViewModelTests.swift | 7 +- Podfile | 4 + Podfile.lock | 2 +- .../ProfileTests/ProfileMock.generated.swift | 531 ++- generateAllMocks.sh | 4 +- 18 files changed, 6439 insertions(+), 183 deletions(-) create mode 100644 Core/CoreTests/CoreMock.generated.swift create mode 100644 Core/CoreTests/DownloadManager/DownloadManagerTests.swift create mode 100644 Core/Mockfile diff --git a/.swiftlint.yml b/.swiftlint.yml index 600160d85..4ed67e1a6 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -18,6 +18,7 @@ opt_in_rules: # some rules are only opt-in excluded: # paths to ignore during linting. Takes precedence over `included`. - Carthage - Pods + - Core/CoreTests - Authorization/AuthorizationTests - Course/CourseTests - Dashboard/DashboardTests diff --git a/Authorization/Authorization/SwiftGen/Strings.swift b/Authorization/Authorization/SwiftGen/Strings.swift index e9358164c..11eeb9a91 100644 --- a/Authorization/Authorization/SwiftGen/Strings.swift +++ b/Authorization/Authorization/SwiftGen/Strings.swift @@ -73,8 +73,8 @@ public enum AuthLocalization { public static let ssoHeading = AuthLocalization.tr("Localizable", "SIGN_IN.SSO_HEADING", fallback: "Start today to build your career with confidence") /// Log in through the national unified sign-on service public static let ssoLogInSubtitle = AuthLocalization.tr("Localizable", "SIGN_IN.SSO_LOG_IN_SUBTITLE", fallback: "Log in through the national unified sign-on service") - /// Sign IN - public static let ssoLogInTitle = AuthLocalization.tr("Localizable", "SIGN_IN.SSO_LOG_IN_TITLE", fallback: "Sign IN") + /// Sign in + public static let ssoLogInTitle = AuthLocalization.tr("Localizable", "SIGN_IN.SSO_LOG_IN_TITLE", fallback: "Sign in") /// An integrated set of knowledge and empowerment programs to develop the components of the endowment sector and its workers public static let ssoSupportingText = AuthLocalization.tr("Localizable", "SIGN_IN.SSO_SUPPORTING_TEXT", fallback: "An integrated set of knowledge and empowerment programs to develop the components of the endowment sector and its workers") /// Welcome back! Sign in to access your courses. diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index f1f58e740..3ba4ab39b 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -158,6 +158,7 @@ 14769D3C2B9822EE00AB36D4 /* CoreAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14769D3B2B9822EE00AB36D4 /* CoreAnalytics.swift */; }; 14D912D92C2553C70077CCCE /* FullStoryConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D912D82C2553C70077CCCE /* FullStoryConfig.swift */; }; 14D912DB2C257E9E0077CCCE /* FullStoryConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D912DA2C257E9E0077CCCE /* FullStoryConfigTests.swift */; }; + 5E58740A2AA9DF20F4644191 /* Pods_App_Core_CoreTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 33FA09A20AAE2B2A0BA89190 /* Pods_App_Core_CoreTests.framework */; }; 9784D47E2BF7762800AFEFFF /* FullScreenErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9784D47D2BF7762800AFEFFF /* FullScreenErrorView.swift */; }; A51CDBE72B6D21F2009B6D4E /* SegmentConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A51CDBE62B6D21F2009B6D4E /* SegmentConfig.swift */; }; A53A32352B233DEC005FE38A /* ThemeConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = A53A32342B233DEC005FE38A /* ThemeConfig.swift */; }; @@ -188,6 +189,8 @@ BAFB99902B14B377007D09F9 /* GoogleConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAFB998F2B14B377007D09F9 /* GoogleConfig.swift */; }; BAFB99922B14E23D007D09F9 /* AppleSignInConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAFB99912B14E23D007D09F9 /* AppleSignInConfig.swift */; }; C8C446EF233F81B9FABB77D2 /* Pods_App_Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 349B90CD6579F7B8D257E515 /* Pods_App_Core.framework */; }; + CE54C2D22CC80D8500E529F9 /* DownloadManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE54C2D12CC80D8500E529F9 /* DownloadManagerTests.swift */; }; + CE953A3B2CD0DA940023D667 /* CoreMock.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE953A3A2CD0DA940023D667 /* CoreMock.generated.swift */; }; CFC84952299F8B890055E497 /* Debounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFC84951299F8B890055E497 /* Debounce.swift */; }; DBF6F2412B014ADA0098414B /* FirebaseConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */; }; DBF6F2462B01DAFE0098414B /* AgreementConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6F2452B01DAFE0098414B /* AgreementConfig.swift */; }; @@ -304,6 +307,7 @@ 02F6EF3A28D9B8EC00835477 /* CourseCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseCellView.swift; sourceTree = ""; }; 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 = ""; }; + 043DD0B526F919DFA1C5E600 /* Pods-App-Core-CoreTests.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core-CoreTests.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests.releaseprod.xcconfig"; 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 = ""; }; @@ -356,6 +360,7 @@ 0770DE6028D0B2CB006D8A5D /* Assets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Assets.swift; sourceTree = ""; }; 07DDFCBC29A780BB00572595 /* UINavigationController+Animation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationController+Animation.swift"; sourceTree = ""; }; 07E0939E2B308D2800F1E4B2 /* Data_Certificate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_Certificate.swift; sourceTree = ""; }; + 0CA4A65A3AECED83CC425A00 /* Pods-CoreTests.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CoreTests.debugstage.xcconfig"; path = "Target Support Files/Pods-CoreTests/Pods-CoreTests.debugstage.xcconfig"; 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 = ""; }; @@ -363,15 +368,23 @@ 14D912DA2C257E9E0077CCCE /* FullStoryConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullStoryConfigTests.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 = ""; }; + 33FA09A20AAE2B2A0BA89190 /* Pods_App_Core_CoreTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_Core_CoreTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 349B90CD6579F7B8D257E515 /* Pods_App_Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = ""; }; + 3C63D5D2247C793C259341B8 /* Pods-App-Core-CoreTests.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core-CoreTests.releasedev.xcconfig"; path = "Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests.releasedev.xcconfig"; sourceTree = ""; }; + 5CEFA8766C44C519B86C681D /* Pods-App-Core-CoreTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core-CoreTests.debug.xcconfig"; path = "Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests.debug.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 = ""; }; + 8F3B171E9FA5E6F40B4890A8 /* Pods-App-Core-CoreTests.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core-CoreTests.debugstage.xcconfig"; path = "Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests.debugstage.xcconfig"; sourceTree = ""; }; + 90D63D7E70B8F5027A211EA3 /* Pods-CoreTests.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CoreTests.debugprod.xcconfig"; path = "Target Support Files/Pods-CoreTests/Pods-CoreTests.debugprod.xcconfig"; sourceTree = ""; }; + 951751177FD4703992DC1491 /* Pods-CoreTests.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CoreTests.releasestage.xcconfig"; path = "Target Support Files/Pods-CoreTests/Pods-CoreTests.releasestage.xcconfig"; sourceTree = ""; }; 9784D47D2BF7762800AFEFFF /* FullScreenErrorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FullScreenErrorView.swift; 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 = ""; }; + 9E0B33614CBD791307FFDEAE /* Pods-App-Core-CoreTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core-CoreTests.release.xcconfig"; path = "Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests.release.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 = ""; }; + B2556B4A2D4F84F402A7A7D9 /* Pods-CoreTests.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CoreTests.releaseprod.xcconfig"; path = "Target Support Files/Pods-CoreTests/Pods-CoreTests.releaseprod.xcconfig"; sourceTree = ""; }; BA30427D2B20B299009B64B7 /* SocialAuthError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SocialAuthError.swift; sourceTree = ""; }; BA4AFB412B5A7A0900A21367 /* VideoDownloadQualityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadQualityView.swift; sourceTree = ""; }; BA4AFB432B6A5AF100A21367 /* CheckBoxView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CheckBoxView.swift; sourceTree = ""; }; @@ -394,16 +407,25 @@ BAFB99832B0E282E007D09F9 /* MicrosoftConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrosoftConfig.swift; sourceTree = ""; }; BAFB998F2B14B377007D09F9 /* GoogleConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleConfig.swift; sourceTree = ""; }; BAFB99912B14E23D007D09F9 /* AppleSignInConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleSignInConfig.swift; sourceTree = ""; }; + C28D4872BAB1276B9AD24A33 /* Pods-CoreTests.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CoreTests.debugdev.xcconfig"; path = "Target Support Files/Pods-CoreTests/Pods-CoreTests.debugdev.xcconfig"; sourceTree = ""; }; C7E5BCE79CE297B20777B27A /* Pods-App-Core.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.debugprod.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.debugprod.xcconfig"; sourceTree = ""; }; + CE54C2D12CC80D8500E529F9 /* DownloadManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadManagerTests.swift; sourceTree = ""; }; + CE953A3A2CD0DA940023D667 /* CoreMock.generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreMock.generated.swift; sourceTree = ""; }; CFC84951299F8B890055E497 /* Debounce.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debounce.swift; sourceTree = ""; }; DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseConfig.swift; sourceTree = ""; }; DBF6F2452B01DAFE0098414B /* AgreementConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AgreementConfig.swift; sourceTree = ""; }; DBF6F2492B0380E00098414B /* FeaturesConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturesConfig.swift; sourceTree = ""; }; + DD27D6BF5EB15C6C8B66969A /* Pods-App-Core-CoreTests.debugdev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core-CoreTests.debugdev.xcconfig"; path = "Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests.debugdev.xcconfig"; sourceTree = ""; }; E055A5382B18DC95008D9E5E /* Theme.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Theme.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E09179FC2B0F204D002AB695 /* ConfigTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigTests.swift; sourceTree = ""; }; E0D586192B2FF74C009B4BA7 /* DiscoveryConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryConfig.swift; sourceTree = ""; }; E0D5861B2B2FF85B009B4BA7 /* RawStringExtactable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RawStringExtactable.swift; sourceTree = ""; }; E0D586352B314CD3009B4BA7 /* LogistrationBottomView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogistrationBottomView.swift; sourceTree = ""; }; + E8D9725130C85DA55AD474A4 /* Pods-CoreTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CoreTests.debug.xcconfig"; path = "Target Support Files/Pods-CoreTests/Pods-CoreTests.debug.xcconfig"; sourceTree = ""; }; + F4E50CE1DB6AA77E9B5D09EF /* Pods-App-Core-CoreTests.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core-CoreTests.debugprod.xcconfig"; path = "Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests.debugprod.xcconfig"; sourceTree = ""; }; + F7ED6F0C276DBD2F1BA38987 /* Pods-CoreTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CoreTests.release.xcconfig"; path = "Target Support Files/Pods-CoreTests/Pods-CoreTests.release.xcconfig"; sourceTree = ""; }; + FB6C49AC95A27A1222AD0F06 /* Pods-App-Core-CoreTests.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core-CoreTests.releasestage.xcconfig"; path = "Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests.releasestage.xcconfig"; sourceTree = ""; }; + FD97820E148E423964AC0CAB /* Pods-CoreTests.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CoreTests.releasedev.xcconfig"; path = "Target Support Files/Pods-CoreTests/Pods-CoreTests.releasedev.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -412,6 +434,7 @@ buildActionMask = 2147483647; files = ( 0716946D296D996900E3DED6 /* Core.framework in Frameworks */, + 5E58740A2AA9DF20F4644191 /* Pods_App_Core_CoreTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -874,11 +897,35 @@ 1A154A95AF4EE85A4A1C083B /* Pods-App-Core.releasedev.xcconfig */, 0E13E9173C9C4CFC19F8B6F2 /* Pods-App-Core.debugstage.xcconfig */, 0754BB7841E3C0F8D6464951 /* Pods-App-Core.releasestage.xcconfig */, + 5CEFA8766C44C519B86C681D /* Pods-App-Core-CoreTests.debug.xcconfig */, + F4E50CE1DB6AA77E9B5D09EF /* Pods-App-Core-CoreTests.debugprod.xcconfig */, + 8F3B171E9FA5E6F40B4890A8 /* Pods-App-Core-CoreTests.debugstage.xcconfig */, + DD27D6BF5EB15C6C8B66969A /* Pods-App-Core-CoreTests.debugdev.xcconfig */, + 9E0B33614CBD791307FFDEAE /* Pods-App-Core-CoreTests.release.xcconfig */, + 043DD0B526F919DFA1C5E600 /* Pods-App-Core-CoreTests.releaseprod.xcconfig */, + FB6C49AC95A27A1222AD0F06 /* Pods-App-Core-CoreTests.releasestage.xcconfig */, + 3C63D5D2247C793C259341B8 /* Pods-App-Core-CoreTests.releasedev.xcconfig */, + E8D9725130C85DA55AD474A4 /* Pods-CoreTests.debug.xcconfig */, + 90D63D7E70B8F5027A211EA3 /* Pods-CoreTests.debugprod.xcconfig */, + 0CA4A65A3AECED83CC425A00 /* Pods-CoreTests.debugstage.xcconfig */, + C28D4872BAB1276B9AD24A33 /* Pods-CoreTests.debugdev.xcconfig */, + F7ED6F0C276DBD2F1BA38987 /* Pods-CoreTests.release.xcconfig */, + B2556B4A2D4F84F402A7A7D9 /* Pods-CoreTests.releaseprod.xcconfig */, + 951751177FD4703992DC1491 /* Pods-CoreTests.releasestage.xcconfig */, + FD97820E148E423964AC0CAB /* Pods-CoreTests.releasedev.xcconfig */, ); name = Pods; path = ../Pods; sourceTree = ""; }; + CE54C2CE2CC80B4A00E529F9 /* DownloadManager */ = { + isa = PBXGroup; + children = ( + CE54C2D12CC80D8500E529F9 /* DownloadManagerTests.swift */, + ); + path = DownloadManager; + sourceTree = ""; + }; CFC84955299FAC4D0055E497 /* Combine */ = { isa = PBXGroup; children = ( @@ -913,6 +960,8 @@ E09179FA2B0F204D002AB695 /* CoreTests */ = { isa = PBXGroup; children = ( + CE953A3A2CD0DA940023D667 /* CoreMock.generated.swift */, + CE54C2CE2CC80B4A00E529F9 /* DownloadManager */, E09179FB2B0F204D002AB695 /* Configuration */, ); path = CoreTests; @@ -933,6 +982,7 @@ children = ( E055A5382B18DC95008D9E5E /* Theme.framework */, 349B90CD6579F7B8D257E515 /* Pods_App_Core.framework */, + 33FA09A20AAE2B2A0BA89190 /* Pods_App_Core_CoreTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -954,9 +1004,11 @@ isa = PBXNativeTarget; buildConfigurationList = 07169476296D996900E3DED6 /* Build configuration list for PBXNativeTarget "CoreTests" */; buildPhases = ( + F87EB93C339DD81527F250AE /* [CP] Check Pods Manifest.lock */, 07169465296D996800E3DED6 /* Sources */, 07169466296D996800E3DED6 /* Frameworks */, 07169467296D996800E3DED6 /* Resources */, + C9AA9371F83D4B112F310DB8 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -1101,6 +1153,23 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App-Core/Pods-App-Core-resources.sh\"\n"; showEnvVarsInLog = 0; }; + C9AA9371F83D4B112F310DB8 /* [CP] Copy Pods Resources */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests-resources-${CONFIGURATION}-input-files.xcfilelist", + ); + name = "[CP] Copy Pods Resources"; + outputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests-resources-${CONFIGURATION}-output-files.xcfilelist", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests-resources.sh\"\n"; + showEnvVarsInLog = 0; + }; ED83AD5255805030E042D62A /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -1123,6 +1192,28 @@ shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; + F87EB93C339DD81527F250AE /* [CP] Check Pods Manifest.lock */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( + ); + outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-App-Core-CoreTests-checkManifestLockResult.txt", + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; + }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -1131,8 +1222,10 @@ buildActionMask = 2147483647; files = ( BAD9CA422B2B140100DE790A /* AgreementConfigTests.swift in Sources */, + CE953A3B2CD0DA940023D667 /* CoreMock.generated.swift in Sources */, 14D912DB2C257E9E0077CCCE /* FullStoryConfigTests.swift in Sources */, E09179FD2B0F204E002AB695 /* ConfigTests.swift in Sources */, + CE54C2D22CC80D8500E529F9 /* DownloadManagerTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1442,6 +1535,7 @@ }; 02DD1C9B29E80CE400F35DCE /* DebugStage */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 8F3B171E9FA5E6F40B4890A8 /* Pods-App-Core-CoreTests.debugstage.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; @@ -1556,6 +1650,7 @@ }; 02DD1C9E29E80CED00F35DCE /* ReleaseStage */ = { isa = XCBuildConfiguration; + baseConfigurationReference = FB6C49AC95A27A1222AD0F06 /* Pods-App-Core-CoreTests.releasestage.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; @@ -1577,6 +1672,7 @@ }; 07169470296D996900E3DED6 /* Debug */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 5CEFA8766C44C519B86C681D /* Pods-App-Core-CoreTests.debug.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; @@ -1599,6 +1695,7 @@ }; 07169471296D996900E3DED6 /* DebugProd */ = { isa = XCBuildConfiguration; + baseConfigurationReference = F4E50CE1DB6AA77E9B5D09EF /* Pods-App-Core-CoreTests.debugprod.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; @@ -1621,6 +1718,7 @@ }; 07169472296D996900E3DED6 /* DebugDev */ = { isa = XCBuildConfiguration; + baseConfigurationReference = DD27D6BF5EB15C6C8B66969A /* Pods-App-Core-CoreTests.debugdev.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; @@ -1643,6 +1741,7 @@ }; 07169473296D996900E3DED6 /* Release */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 9E0B33614CBD791307FFDEAE /* Pods-App-Core-CoreTests.release.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; @@ -1664,6 +1763,7 @@ }; 07169474296D996900E3DED6 /* ReleaseProd */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 043DD0B526F919DFA1C5E600 /* Pods-App-Core-CoreTests.releaseprod.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; @@ -1685,6 +1785,7 @@ }; 07169475296D996900E3DED6 /* ReleaseDev */ = { isa = XCBuildConfiguration; + baseConfigurationReference = 3C63D5D2247C793C259341B8 /* Pods-App-Core-CoreTests.releasedev.xcconfig */; buildSettings = { CLANG_ENABLE_MODULES = YES; CODE_SIGN_STYLE = Automatic; diff --git a/Core/Core/Data/CoreStorage.swift b/Core/Core/Data/CoreStorage.swift index ef09cd079..021d32ec8 100644 --- a/Core/Core/Data/CoreStorage.swift +++ b/Core/Core/Data/CoreStorage.swift @@ -7,6 +7,7 @@ import Foundation +//sourcery: AutoMockable public protocol CoreStorage { var accessToken: String? {get set} var refreshToken: String? {get set} diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index dad856a1e..d21a95d69 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -340,7 +340,7 @@ public class DownloadManager: DownloadManagerProtocol { return updatedSequentials } - private func calculateFolderSize(at url: URL) throws -> Int { + func calculateFolderSize(at url: URL) throws -> Int { let fileManager = FileManager.default let resourceKeys: [URLResourceKey] = [.isDirectoryKey, .fileSizeKey] var totalSize: Int64 = 0 @@ -705,7 +705,7 @@ public class DownloadManager: DownloadManagerProtocol { } } - private func isMD5Hash(_ folderName: String) -> Bool { + func isMD5Hash(_ folderName: String) -> Bool { let md5Regex = "^[a-fA-F0-9]{32}$" let predicate = NSPredicate(format: "SELF MATCHES %@", md5Regex) return predicate.evaluate(with: folderName) diff --git a/Core/CoreTests/CoreMock.generated.swift b/Core/CoreTests/CoreMock.generated.swift new file mode 100644 index 000000000..4ec87ace6 --- /dev/null +++ b/Core/CoreTests/CoreMock.generated.swift @@ -0,0 +1,3641 @@ +// Generated using Sourcery 2.1.2 — https://github.com/krzysztofzablocki/Sourcery +// DO NOT EDIT + + +// Generated with SwiftyMocky 4.2.0 +// Required Sourcery: 1.8.0 + + +import SwiftyMocky +import XCTest +import Core +import Foundation +import SwiftUI +import Combine + + +// MARK: - AuthInteractorProtocol + +open class AuthInteractorProtocolMock: AuthInteractorProtocol, 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 = [] } + } + + + + + + @discardableResult + open func login(username: String, password: String) throws -> User { + addInvocation(.m_login__username_usernamepassword_password(Parameter.value(`username`), Parameter.value(`password`))) + let perform = methodPerformValue(.m_login__username_usernamepassword_password(Parameter.value(`username`), Parameter.value(`password`))) as? (String, String) -> Void + perform?(`username`, `password`) + var __value: User + do { + __value = try methodReturnValue(.m_login__username_usernamepassword_password(Parameter.value(`username`), Parameter.value(`password`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for login(username: String, password: String). Use given") + Failure("Stub return value not specified for login(username: String, password: String). Use given") + } catch { + throw error + } + return __value + } + + @discardableResult + open func login(externalToken: String, backend: String) throws -> User { + addInvocation(.m_login__externalToken_externalTokenbackend_backend(Parameter.value(`externalToken`), Parameter.value(`backend`))) + let perform = methodPerformValue(.m_login__externalToken_externalTokenbackend_backend(Parameter.value(`externalToken`), Parameter.value(`backend`))) as? (String, String) -> Void + perform?(`externalToken`, `backend`) + var __value: User + do { + __value = try methodReturnValue(.m_login__externalToken_externalTokenbackend_backend(Parameter.value(`externalToken`), Parameter.value(`backend`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for login(externalToken: String, backend: String). Use given") + Failure("Stub return value not specified for login(externalToken: String, backend: String). Use given") + } catch { + throw error + } + return __value + } + + open func login(ssoToken: String) throws -> User { + addInvocation(.m_login__ssoToken_ssoToken(Parameter.value(`ssoToken`))) + let perform = methodPerformValue(.m_login__ssoToken_ssoToken(Parameter.value(`ssoToken`))) as? (String) -> Void + perform?(`ssoToken`) + var __value: User + do { + __value = try methodReturnValue(.m_login__ssoToken_ssoToken(Parameter.value(`ssoToken`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for login(ssoToken: String). Use given") + Failure("Stub return value not specified for login(ssoToken: String). Use given") + } catch { + throw error + } + return __value + } + + open func resetPassword(email: String) throws -> ResetPassword { + addInvocation(.m_resetPassword__email_email(Parameter.value(`email`))) + let perform = methodPerformValue(.m_resetPassword__email_email(Parameter.value(`email`))) as? (String) -> Void + perform?(`email`) + var __value: ResetPassword + do { + __value = try methodReturnValue(.m_resetPassword__email_email(Parameter.value(`email`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for resetPassword(email: String). Use given") + Failure("Stub return value not specified for resetPassword(email: String). Use given") + } catch { + throw error + } + return __value + } + + open func getCookies(force: Bool) throws { + addInvocation(.m_getCookies__force_force(Parameter.value(`force`))) + let perform = methodPerformValue(.m_getCookies__force_force(Parameter.value(`force`))) as? (Bool) -> Void + perform?(`force`) + do { + _ = try methodReturnValue(.m_getCookies__force_force(Parameter.value(`force`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func getRegistrationFields() throws -> [PickerFields] { + addInvocation(.m_getRegistrationFields) + let perform = methodPerformValue(.m_getRegistrationFields) as? () -> Void + perform?() + var __value: [PickerFields] + do { + __value = try methodReturnValue(.m_getRegistrationFields).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for getRegistrationFields(). Use given") + Failure("Stub return value not specified for getRegistrationFields(). Use given") + } catch { + throw error + } + return __value + } + + open func registerUser(fields: [String: String], isSocial: Bool) throws -> User { + addInvocation(.m_registerUser__fields_fieldsisSocial_isSocial(Parameter<[String: String]>.value(`fields`), Parameter.value(`isSocial`))) + let perform = methodPerformValue(.m_registerUser__fields_fieldsisSocial_isSocial(Parameter<[String: String]>.value(`fields`), Parameter.value(`isSocial`))) as? ([String: String], Bool) -> Void + perform?(`fields`, `isSocial`) + var __value: User + do { + __value = try methodReturnValue(.m_registerUser__fields_fieldsisSocial_isSocial(Parameter<[String: String]>.value(`fields`), Parameter.value(`isSocial`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for registerUser(fields: [String: String], isSocial: Bool). Use given") + Failure("Stub return value not specified for registerUser(fields: [String: String], isSocial: Bool). Use given") + } catch { + throw error + } + return __value + } + + open func validateRegistrationFields(fields: [String: String]) throws -> [String: String] { + addInvocation(.m_validateRegistrationFields__fields_fields(Parameter<[String: String]>.value(`fields`))) + let perform = methodPerformValue(.m_validateRegistrationFields__fields_fields(Parameter<[String: String]>.value(`fields`))) as? ([String: String]) -> Void + perform?(`fields`) + var __value: [String: String] + do { + __value = try methodReturnValue(.m_validateRegistrationFields__fields_fields(Parameter<[String: String]>.value(`fields`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for validateRegistrationFields(fields: [String: String]). Use given") + Failure("Stub return value not specified for validateRegistrationFields(fields: [String: String]). Use given") + } catch { + throw error + } + return __value + } + + + fileprivate enum MethodType { + case m_login__username_usernamepassword_password(Parameter, Parameter) + case m_login__externalToken_externalTokenbackend_backend(Parameter, Parameter) + case m_login__ssoToken_ssoToken(Parameter) + case m_resetPassword__email_email(Parameter) + case m_getCookies__force_force(Parameter) + case m_getRegistrationFields + case m_registerUser__fields_fieldsisSocial_isSocial(Parameter<[String: String]>, Parameter) + case m_validateRegistrationFields__fields_fields(Parameter<[String: String]>) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_login__username_usernamepassword_password(let lhsUsername, let lhsPassword), .m_login__username_usernamepassword_password(let rhsUsername, let rhsPassword)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUsername, rhs: rhsUsername, with: matcher), lhsUsername, rhsUsername, "username")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsPassword, rhs: rhsPassword, with: matcher), lhsPassword, rhsPassword, "password")) + return Matcher.ComparisonResult(results) + + case (.m_login__externalToken_externalTokenbackend_backend(let lhsExternaltoken, let lhsBackend), .m_login__externalToken_externalTokenbackend_backend(let rhsExternaltoken, let rhsBackend)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsExternaltoken, rhs: rhsExternaltoken, with: matcher), lhsExternaltoken, rhsExternaltoken, "externalToken")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBackend, rhs: rhsBackend, with: matcher), lhsBackend, rhsBackend, "backend")) + return Matcher.ComparisonResult(results) + + case (.m_login__ssoToken_ssoToken(let lhsSsotoken), .m_login__ssoToken_ssoToken(let rhsSsotoken)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSsotoken, rhs: rhsSsotoken, with: matcher), lhsSsotoken, rhsSsotoken, "ssoToken")) + return Matcher.ComparisonResult(results) + + case (.m_resetPassword__email_email(let lhsEmail), .m_resetPassword__email_email(let rhsEmail)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEmail, rhs: rhsEmail, with: matcher), lhsEmail, rhsEmail, "email")) + return Matcher.ComparisonResult(results) + + case (.m_getCookies__force_force(let lhsForce), .m_getCookies__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_getRegistrationFields, .m_getRegistrationFields): return .match + + case (.m_registerUser__fields_fieldsisSocial_isSocial(let lhsFields, let lhsIssocial), .m_registerUser__fields_fieldsisSocial_isSocial(let rhsFields, let rhsIssocial)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsFields, rhs: rhsFields, with: matcher), lhsFields, rhsFields, "fields")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsIssocial, rhs: rhsIssocial, with: matcher), lhsIssocial, rhsIssocial, "isSocial")) + return Matcher.ComparisonResult(results) + + case (.m_validateRegistrationFields__fields_fields(let lhsFields), .m_validateRegistrationFields__fields_fields(let rhsFields)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsFields, rhs: rhsFields, with: matcher), lhsFields, rhsFields, "fields")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_login__username_usernamepassword_password(p0, p1): return p0.intValue + p1.intValue + case let .m_login__externalToken_externalTokenbackend_backend(p0, p1): return p0.intValue + p1.intValue + case let .m_login__ssoToken_ssoToken(p0): return p0.intValue + case let .m_resetPassword__email_email(p0): return p0.intValue + case let .m_getCookies__force_force(p0): return p0.intValue + case .m_getRegistrationFields: return 0 + case let .m_registerUser__fields_fieldsisSocial_isSocial(p0, p1): return p0.intValue + p1.intValue + case let .m_validateRegistrationFields__fields_fields(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_login__username_usernamepassword_password: return ".login(username:password:)" + case .m_login__externalToken_externalTokenbackend_backend: return ".login(externalToken:backend:)" + case .m_login__ssoToken_ssoToken: return ".login(ssoToken:)" + case .m_resetPassword__email_email: return ".resetPassword(email:)" + case .m_getCookies__force_force: return ".getCookies(force:)" + case .m_getRegistrationFields: return ".getRegistrationFields()" + case .m_registerUser__fields_fieldsisSocial_isSocial: return ".registerUser(fields:isSocial:)" + case .m_validateRegistrationFields__fields_fields: return ".validateRegistrationFields(fields:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + @discardableResult + public static func login(username: Parameter, password: Parameter, willReturn: User...) -> MethodStub { + return Given(method: .m_login__username_usernamepassword_password(`username`, `password`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + @discardableResult + public static func login(externalToken: Parameter, backend: Parameter, willReturn: User...) -> MethodStub { + return Given(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func login(ssoToken: Parameter, willReturn: User...) -> MethodStub { + return Given(method: .m_login__ssoToken_ssoToken(`ssoToken`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func resetPassword(email: Parameter, willReturn: ResetPassword...) -> MethodStub { + return Given(method: .m_resetPassword__email_email(`email`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getRegistrationFields(willReturn: [PickerFields]...) -> MethodStub { + return Given(method: .m_getRegistrationFields, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func registerUser(fields: Parameter<[String: String]>, isSocial: Parameter, willReturn: User...) -> MethodStub { + return Given(method: .m_registerUser__fields_fieldsisSocial_isSocial(`fields`, `isSocial`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func validateRegistrationFields(fields: Parameter<[String: String]>, willReturn: [String: String]...) -> MethodStub { + return Given(method: .m_validateRegistrationFields__fields_fields(`fields`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + @discardableResult + public static func login(username: Parameter, password: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_login__username_usernamepassword_password(`username`, `password`), products: willThrow.map({ StubProduct.throw($0) })) + } + @discardableResult + public static func login(username: Parameter, password: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_login__username_usernamepassword_password(`username`, `password`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (User).self) + willProduce(stubber) + return given + } + @discardableResult + public static func login(externalToken: Parameter, backend: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`), products: willThrow.map({ StubProduct.throw($0) })) + } + @discardableResult + public static func login(externalToken: Parameter, backend: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (User).self) + willProduce(stubber) + return given + } + public static func login(ssoToken: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_login__ssoToken_ssoToken(`ssoToken`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func login(ssoToken: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_login__ssoToken_ssoToken(`ssoToken`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (User).self) + willProduce(stubber) + return given + } + public static func resetPassword(email: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_resetPassword__email_email(`email`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func resetPassword(email: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_resetPassword__email_email(`email`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (ResetPassword).self) + willProduce(stubber) + return given + } + public static func getCookies(force: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_getCookies__force_force(`force`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getCookies(force: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_getCookies__force_force(`force`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func getRegistrationFields(willThrow: Error...) -> MethodStub { + return Given(method: .m_getRegistrationFields, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getRegistrationFields(willProduce: (StubberThrows<[PickerFields]>) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_getRegistrationFields, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: ([PickerFields]).self) + willProduce(stubber) + return given + } + public static func registerUser(fields: Parameter<[String: String]>, isSocial: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_registerUser__fields_fieldsisSocial_isSocial(`fields`, `isSocial`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func registerUser(fields: Parameter<[String: String]>, isSocial: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_registerUser__fields_fieldsisSocial_isSocial(`fields`, `isSocial`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (User).self) + willProduce(stubber) + return given + } + public static func validateRegistrationFields(fields: Parameter<[String: String]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_validateRegistrationFields__fields_fields(`fields`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func validateRegistrationFields(fields: Parameter<[String: String]>, willProduce: (StubberThrows<[String: String]>) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_validateRegistrationFields__fields_fields(`fields`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: ([String: String]).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + @discardableResult + public static func login(username: Parameter, password: Parameter) -> Verify { return Verify(method: .m_login__username_usernamepassword_password(`username`, `password`))} + @discardableResult + public static func login(externalToken: Parameter, backend: Parameter) -> Verify { return Verify(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`))} + public static func login(ssoToken: Parameter) -> Verify { return Verify(method: .m_login__ssoToken_ssoToken(`ssoToken`))} + public static func resetPassword(email: Parameter) -> Verify { return Verify(method: .m_resetPassword__email_email(`email`))} + public static func getCookies(force: Parameter) -> Verify { return Verify(method: .m_getCookies__force_force(`force`))} + public static func getRegistrationFields() -> Verify { return Verify(method: .m_getRegistrationFields)} + public static func registerUser(fields: Parameter<[String: String]>, isSocial: Parameter) -> Verify { return Verify(method: .m_registerUser__fields_fieldsisSocial_isSocial(`fields`, `isSocial`))} + public static func validateRegistrationFields(fields: Parameter<[String: String]>) -> Verify { return Verify(method: .m_validateRegistrationFields__fields_fields(`fields`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + @discardableResult + public static func login(username: Parameter, password: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_login__username_usernamepassword_password(`username`, `password`), performs: perform) + } + @discardableResult + public static func login(externalToken: Parameter, backend: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`), performs: perform) + } + public static func login(ssoToken: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_login__ssoToken_ssoToken(`ssoToken`), performs: perform) + } + public static func resetPassword(email: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_resetPassword__email_email(`email`), performs: perform) + } + public static func getCookies(force: Parameter, perform: @escaping (Bool) -> Void) -> Perform { + return Perform(method: .m_getCookies__force_force(`force`), performs: perform) + } + public static func getRegistrationFields(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getRegistrationFields, performs: perform) + } + public static func registerUser(fields: Parameter<[String: String]>, isSocial: Parameter, perform: @escaping ([String: String], Bool) -> Void) -> Perform { + return Perform(method: .m_registerUser__fields_fieldsisSocial_isSocial(`fields`, `isSocial`), performs: perform) + } + public static func validateRegistrationFields(fields: Parameter<[String: String]>, perform: @escaping ([String: String]) -> Void) -> Perform { + return Perform(method: .m_validateRegistrationFields__fields_fields(`fields`), 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: - BaseRouter + +open class BaseRouterMock: BaseRouter, 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 backToRoot(animated: Bool) { + addInvocation(.m_backToRoot__animated_animated(Parameter.value(`animated`))) + let perform = methodPerformValue(.m_backToRoot__animated_animated(Parameter.value(`animated`))) as? (Bool) -> Void + perform?(`animated`) + } + + open func back(animated: Bool) { + addInvocation(.m_back__animated_animated(Parameter.value(`animated`))) + let perform = methodPerformValue(.m_back__animated_animated(Parameter.value(`animated`))) as? (Bool) -> Void + perform?(`animated`) + } + + open func backWithFade() { + addInvocation(.m_backWithFade) + let perform = methodPerformValue(.m_backWithFade) as? () -> Void + perform?() + } + + open func dismiss(animated: Bool) { + addInvocation(.m_dismiss__animated_animated(Parameter.value(`animated`))) + let perform = methodPerformValue(.m_dismiss__animated_animated(Parameter.value(`animated`))) as? (Bool) -> Void + perform?(`animated`) + } + + open func removeLastView(controllers: Int) { + addInvocation(.m_removeLastView__controllers_controllers(Parameter.value(`controllers`))) + let perform = methodPerformValue(.m_removeLastView__controllers_controllers(Parameter.value(`controllers`))) as? (Int) -> Void + perform?(`controllers`) + } + + open func showMainOrWhatsNewScreen(sourceScreen: LogistrationSourceScreen) { + addInvocation(.m_showMainOrWhatsNewScreen__sourceScreen_sourceScreen(Parameter.value(`sourceScreen`))) + let perform = methodPerformValue(.m_showMainOrWhatsNewScreen__sourceScreen_sourceScreen(Parameter.value(`sourceScreen`))) as? (LogistrationSourceScreen) -> Void + perform?(`sourceScreen`) + } + + open func showStartupScreen() { + addInvocation(.m_showStartupScreen) + let perform = methodPerformValue(.m_showStartupScreen) as? () -> Void + perform?() + } + + open func showLoginScreen(sourceScreen: LogistrationSourceScreen) { + addInvocation(.m_showLoginScreen__sourceScreen_sourceScreen(Parameter.value(`sourceScreen`))) + let perform = methodPerformValue(.m_showLoginScreen__sourceScreen_sourceScreen(Parameter.value(`sourceScreen`))) as? (LogistrationSourceScreen) -> Void + perform?(`sourceScreen`) + } + + open func showRegisterScreen(sourceScreen: LogistrationSourceScreen) { + addInvocation(.m_showRegisterScreen__sourceScreen_sourceScreen(Parameter.value(`sourceScreen`))) + let perform = methodPerformValue(.m_showRegisterScreen__sourceScreen_sourceScreen(Parameter.value(`sourceScreen`))) as? (LogistrationSourceScreen) -> Void + perform?(`sourceScreen`) + } + + open func showForgotPasswordScreen() { + addInvocation(.m_showForgotPasswordScreen) + let perform = methodPerformValue(.m_showForgotPasswordScreen) as? () -> Void + perform?() + } + + open func showDiscoveryScreen(searchQuery: String?, sourceScreen: LogistrationSourceScreen) { + addInvocation(.m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(Parameter.value(`searchQuery`), Parameter.value(`sourceScreen`))) + let perform = methodPerformValue(.m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(Parameter.value(`searchQuery`), Parameter.value(`sourceScreen`))) as? (String?, LogistrationSourceScreen) -> Void + perform?(`searchQuery`, `sourceScreen`) + } + + open func showWebBrowser(title: String, url: URL) { + addInvocation(.m_showWebBrowser__title_titleurl_url(Parameter.value(`title`), Parameter.value(`url`))) + let perform = methodPerformValue(.m_showWebBrowser__title_titleurl_url(Parameter.value(`title`), Parameter.value(`url`))) as? (String, URL) -> Void + perform?(`title`, `url`) + } + + open func showSSOWebBrowser(title: String) { + addInvocation(.m_showSSOWebBrowser__title_title(Parameter.value(`title`))) + let perform = methodPerformValue(.m_showSSOWebBrowser__title_title(Parameter.value(`title`))) as? (String) -> Void + perform?(`title`) + } + + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { + addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) + let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void + perform?(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`) + } + + open func presentAlert(alertTitle: String, alertMessage: String, nextSectionName: String?, action: String, image: SwiftUI.Image, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, nextSectionTapped: @escaping () -> Void) { + addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`nextSectionName`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter<() -> Void>.value(`nextSectionTapped`))) + let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`nextSectionName`), Parameter.value(`action`), Parameter.value(`image`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter<() -> Void>.value(`nextSectionTapped`))) as? (String, String, String?, String, SwiftUI.Image, @escaping () -> Void, @escaping () -> Void, @escaping () -> Void) -> Void + perform?(`alertTitle`, `alertMessage`, `nextSectionName`, `action`, `image`, `onCloseTapped`, `okTapped`, `nextSectionTapped`) + } + + open func presentView(transitionStyle: UIModalTransitionStyle, view: any View, 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) { + addInvocation(.m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(Parameter.value(`transitionStyle`), Parameter.value(`animated`), Parameter<() -> any View>.any)) + let perform = methodPerformValue(.m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content(Parameter.value(`transitionStyle`), Parameter.value(`animated`), Parameter<() -> any View>.any)) as? (UIModalTransitionStyle, Bool, () -> any View) -> Void + perform?(`transitionStyle`, `animated`, `content`) + } + + + fileprivate enum MethodType { + case m_backToRoot__animated_animated(Parameter) + case m_back__animated_animated(Parameter) + case m_backWithFade + case m_dismiss__animated_animated(Parameter) + case m_removeLastView__controllers_controllers(Parameter) + case m_showMainOrWhatsNewScreen__sourceScreen_sourceScreen(Parameter) + case m_showStartupScreen + case m_showLoginScreen__sourceScreen_sourceScreen(Parameter) + case m_showRegisterScreen__sourceScreen_sourceScreen(Parameter) + case m_showForgotPasswordScreen + case m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(Parameter, Parameter) + case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) + case m_showSSOWebBrowser__title_title(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_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 { + switch (lhs, rhs) { + case (.m_backToRoot__animated_animated(let lhsAnimated), .m_backToRoot__animated_animated(let rhsAnimated)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAnimated, rhs: rhsAnimated, with: matcher), lhsAnimated, rhsAnimated, "animated")) + return Matcher.ComparisonResult(results) + + case (.m_back__animated_animated(let lhsAnimated), .m_back__animated_animated(let rhsAnimated)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAnimated, rhs: rhsAnimated, with: matcher), lhsAnimated, rhsAnimated, "animated")) + return Matcher.ComparisonResult(results) + + case (.m_backWithFade, .m_backWithFade): return .match + + case (.m_dismiss__animated_animated(let lhsAnimated), .m_dismiss__animated_animated(let rhsAnimated)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAnimated, rhs: rhsAnimated, with: matcher), lhsAnimated, rhsAnimated, "animated")) + return Matcher.ComparisonResult(results) + + case (.m_removeLastView__controllers_controllers(let lhsControllers), .m_removeLastView__controllers_controllers(let rhsControllers)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsControllers, rhs: rhsControllers, with: matcher), lhsControllers, rhsControllers, "controllers")) + return Matcher.ComparisonResult(results) + + case (.m_showMainOrWhatsNewScreen__sourceScreen_sourceScreen(let lhsSourcescreen), .m_showMainOrWhatsNewScreen__sourceScreen_sourceScreen(let rhsSourcescreen)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSourcescreen, rhs: rhsSourcescreen, with: matcher), lhsSourcescreen, rhsSourcescreen, "sourceScreen")) + return Matcher.ComparisonResult(results) + + case (.m_showStartupScreen, .m_showStartupScreen): return .match + + case (.m_showLoginScreen__sourceScreen_sourceScreen(let lhsSourcescreen), .m_showLoginScreen__sourceScreen_sourceScreen(let rhsSourcescreen)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSourcescreen, rhs: rhsSourcescreen, with: matcher), lhsSourcescreen, rhsSourcescreen, "sourceScreen")) + return Matcher.ComparisonResult(results) + + case (.m_showRegisterScreen__sourceScreen_sourceScreen(let lhsSourcescreen), .m_showRegisterScreen__sourceScreen_sourceScreen(let rhsSourcescreen)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSourcescreen, rhs: rhsSourcescreen, with: matcher), lhsSourcescreen, rhsSourcescreen, "sourceScreen")) + return Matcher.ComparisonResult(results) + + case (.m_showForgotPasswordScreen, .m_showForgotPasswordScreen): return .match + + case (.m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(let lhsSearchquery, let lhsSourcescreen), .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(let rhsSearchquery, let rhsSourcescreen)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSearchquery, rhs: rhsSearchquery, with: matcher), lhsSearchquery, rhsSearchquery, "searchQuery")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSourcescreen, rhs: rhsSourcescreen, with: matcher), lhsSourcescreen, rhsSourcescreen, "sourceScreen")) + return Matcher.ComparisonResult(results) + + case (.m_showWebBrowser__title_titleurl_url(let lhsTitle, let lhsUrl), .m_showWebBrowser__title_titleurl_url(let rhsTitle, let rhsUrl)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTitle, rhs: rhsTitle, with: matcher), lhsTitle, rhsTitle, "title")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUrl, rhs: rhsUrl, with: matcher), lhsUrl, rhsUrl, "url")) + return Matcher.ComparisonResult(results) + + case (.m_showSSOWebBrowser__title_title(let lhsTitle), .m_showSSOWebBrowser__title_title(let rhsTitle)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTitle, rhs: rhsTitle, with: matcher), lhsTitle, rhsTitle, "title")) + return Matcher.ComparisonResult(results) + + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let lhsAlerttitle, let lhsAlertmessage, let lhsPositiveaction, let lhsOnclosetapped, let lhsOktapped, let lhsType), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let rhsAlerttitle, let rhsAlertmessage, let rhsPositiveaction, let rhsOnclosetapped, let rhsOktapped, let rhsType)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlertmessage, rhs: rhsAlertmessage, with: matcher), lhsAlertmessage, rhsAlertmessage, "alertMessage")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsPositiveaction, rhs: rhsPositiveaction, with: matcher), lhsPositiveaction, rhsPositiveaction, "positiveAction")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOnclosetapped, rhs: rhsOnclosetapped, with: matcher), lhsOnclosetapped, rhsOnclosetapped, "onCloseTapped")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOktapped, rhs: rhsOktapped, with: matcher), lhsOktapped, rhsOktapped, "okTapped")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsType, rhs: rhsType, with: matcher), lhsType, rhsType, "type")) + return Matcher.ComparisonResult(results) + + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(let lhsAlerttitle, let lhsAlertmessage, let lhsNextsectionname, let lhsAction, let lhsImage, let lhsOnclosetapped, let lhsOktapped, let lhsNextsectiontapped), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagenextSectionName_nextSectionNameaction_actionimage_imageonCloseTapped_onCloseTappedokTapped_okTappednextSectionTapped_nextSectionTapped(let rhsAlerttitle, let rhsAlertmessage, let rhsNextsectionname, let rhsAction, let rhsImage, let rhsOnclosetapped, let rhsOktapped, let rhsNextsectiontapped)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlertmessage, rhs: rhsAlertmessage, with: matcher), lhsAlertmessage, rhsAlertmessage, "alertMessage")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectionname, rhs: rhsNextsectionname, with: matcher), lhsNextsectionname, rhsNextsectionname, "nextSectionName")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsImage, rhs: rhsImage, with: matcher), lhsImage, rhsImage, "image")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOnclosetapped, rhs: rhsOnclosetapped, with: matcher), lhsOnclosetapped, rhsOnclosetapped, "onCloseTapped")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsOktapped, rhs: rhsOktapped, with: matcher), lhsOktapped, rhsOktapped, "okTapped")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsNextsectiontapped, rhs: rhsNextsectiontapped, with: matcher), lhsNextsectiontapped, rhsNextsectiontapped, "nextSectionTapped")) + return Matcher.ComparisonResult(results) + + case (.m_presentView__transitionStyle_transitionStyleview_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)): + 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: lhsAnimated, rhs: rhsAnimated, with: matcher), lhsAnimated, rhsAnimated, "animated")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsContent, rhs: rhsContent, with: matcher), lhsContent, rhsContent, "content")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case let .m_backToRoot__animated_animated(p0): return p0.intValue + case let .m_back__animated_animated(p0): return p0.intValue + case .m_backWithFade: return 0 + case let .m_dismiss__animated_animated(p0): return p0.intValue + case let .m_removeLastView__controllers_controllers(p0): return p0.intValue + case let .m_showMainOrWhatsNewScreen__sourceScreen_sourceScreen(p0): return p0.intValue + case .m_showStartupScreen: return 0 + case let .m_showLoginScreen__sourceScreen_sourceScreen(p0): return p0.intValue + case let .m_showRegisterScreen__sourceScreen_sourceScreen(p0): return p0.intValue + case .m_showForgotPasswordScreen: return 0 + case let .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(p0, p1): return p0.intValue + p1.intValue + case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue + case let .m_showSSOWebBrowser__title_title(p0): return p0.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_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 + } + } + func assertionName() -> String { + switch self { + case .m_backToRoot__animated_animated: return ".backToRoot(animated:)" + case .m_back__animated_animated: return ".back(animated:)" + case .m_backWithFade: return ".backWithFade()" + case .m_dismiss__animated_animated: return ".dismiss(animated:)" + case .m_removeLastView__controllers_controllers: return ".removeLastView(controllers:)" + case .m_showMainOrWhatsNewScreen__sourceScreen_sourceScreen: return ".showMainOrWhatsNewScreen(sourceScreen:)" + case .m_showStartupScreen: return ".showStartupScreen()" + case .m_showLoginScreen__sourceScreen_sourceScreen: return ".showLoginScreen(sourceScreen:)" + case .m_showRegisterScreen__sourceScreen_sourceScreen: return ".showRegisterScreen(sourceScreen:)" + case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" + case .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen: return ".showDiscoveryScreen(searchQuery:sourceScreen:)" + case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" + case .m_showSSOWebBrowser__title_title: return ".showSSOWebBrowser(title:)" + 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_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" + case .m_presentView__transitionStyle_transitionStyleanimated_animatedcontent_content: return ".presentView(transitionStyle:animated:content:)" + } + } + } + + 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 backToRoot(animated: Parameter) -> Verify { return Verify(method: .m_backToRoot__animated_animated(`animated`))} + public static func back(animated: Parameter) -> Verify { return Verify(method: .m_back__animated_animated(`animated`))} + public static func backWithFade() -> Verify { return Verify(method: .m_backWithFade)} + public static func dismiss(animated: Parameter) -> Verify { return Verify(method: .m_dismiss__animated_animated(`animated`))} + public static func removeLastView(controllers: Parameter) -> Verify { return Verify(method: .m_removeLastView__controllers_controllers(`controllers`))} + public static func showMainOrWhatsNewScreen(sourceScreen: Parameter) -> Verify { return Verify(method: .m_showMainOrWhatsNewScreen__sourceScreen_sourceScreen(`sourceScreen`))} + public static func showStartupScreen() -> Verify { return Verify(method: .m_showStartupScreen)} + public static func showLoginScreen(sourceScreen: Parameter) -> Verify { return Verify(method: .m_showLoginScreen__sourceScreen_sourceScreen(`sourceScreen`))} + public static func showRegisterScreen(sourceScreen: Parameter) -> Verify { return Verify(method: .m_showRegisterScreen__sourceScreen_sourceScreen(`sourceScreen`))} + public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} + public static func showDiscoveryScreen(searchQuery: Parameter, sourceScreen: Parameter) -> Verify { return Verify(method: .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(`searchQuery`, `sourceScreen`))} + public static func showWebBrowser(title: Parameter, url: Parameter) -> Verify { return Verify(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`))} + public static func showSSOWebBrowser(title: Parameter) -> Verify { return Verify(method: .m_showSSOWebBrowser__title_title(`title`))} + 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, 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`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func backToRoot(animated: Parameter, perform: @escaping (Bool) -> Void) -> Perform { + return Perform(method: .m_backToRoot__animated_animated(`animated`), performs: perform) + } + public static func back(animated: Parameter, perform: @escaping (Bool) -> Void) -> Perform { + return Perform(method: .m_back__animated_animated(`animated`), performs: perform) + } + public static func backWithFade(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_backWithFade, performs: perform) + } + public static func dismiss(animated: Parameter, perform: @escaping (Bool) -> Void) -> Perform { + return Perform(method: .m_dismiss__animated_animated(`animated`), performs: perform) + } + public static func removeLastView(controllers: Parameter, perform: @escaping (Int) -> Void) -> Perform { + return Perform(method: .m_removeLastView__controllers_controllers(`controllers`), performs: perform) + } + public static func showMainOrWhatsNewScreen(sourceScreen: Parameter, perform: @escaping (LogistrationSourceScreen) -> Void) -> Perform { + return Perform(method: .m_showMainOrWhatsNewScreen__sourceScreen_sourceScreen(`sourceScreen`), performs: perform) + } + public static func showStartupScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showStartupScreen, performs: perform) + } + public static func showLoginScreen(sourceScreen: Parameter, perform: @escaping (LogistrationSourceScreen) -> Void) -> Perform { + return Perform(method: .m_showLoginScreen__sourceScreen_sourceScreen(`sourceScreen`), performs: perform) + } + public static func showRegisterScreen(sourceScreen: Parameter, perform: @escaping (LogistrationSourceScreen) -> Void) -> Perform { + return Perform(method: .m_showRegisterScreen__sourceScreen_sourceScreen(`sourceScreen`), performs: perform) + } + public static func showForgotPasswordScreen(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_showForgotPasswordScreen, performs: perform) + } + public static func showDiscoveryScreen(searchQuery: Parameter, sourceScreen: Parameter, perform: @escaping (String?, LogistrationSourceScreen) -> Void) -> Perform { + return Perform(method: .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(`searchQuery`, `sourceScreen`), performs: perform) + } + public static func showWebBrowser(title: Parameter, url: Parameter, perform: @escaping (String, URL) -> Void) -> Perform { + return Perform(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`), performs: perform) + } + public static func showSSOWebBrowser(title: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_showSSOWebBrowser__title_title(`title`), performs: perform) + } + public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { + return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) + } + public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, 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, 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) + } + } + + 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: - ConnectivityProtocol + +open class ConnectivityProtocolMock: ConnectivityProtocol, 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 isInternetAvaliable: Bool { + get { invocations.append(.p_isInternetAvaliable_get); return __p_isInternetAvaliable ?? givenGetterValue(.p_isInternetAvaliable_get, "ConnectivityProtocolMock - stub value for isInternetAvaliable was not defined") } + } + private var __p_isInternetAvaliable: (Bool)? + + public var isMobileData: Bool { + get { invocations.append(.p_isMobileData_get); return __p_isMobileData ?? givenGetterValue(.p_isMobileData_get, "ConnectivityProtocolMock - stub value for isMobileData was not defined") } + } + private var __p_isMobileData: (Bool)? + + public var internetReachableSubject: CurrentValueSubject { + get { invocations.append(.p_internetReachableSubject_get); return __p_internetReachableSubject ?? givenGetterValue(.p_internetReachableSubject_get, "ConnectivityProtocolMock - stub value for internetReachableSubject was not defined") } + } + private var __p_internetReachableSubject: (CurrentValueSubject)? + + + + + + + fileprivate enum MethodType { + case p_isInternetAvaliable_get + case p_isMobileData_get + case p_internetReachableSubject_get + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { case (.p_isInternetAvaliable_get,.p_isInternetAvaliable_get): return Matcher.ComparisonResult.match + case (.p_isMobileData_get,.p_isMobileData_get): return Matcher.ComparisonResult.match + case (.p_internetReachableSubject_get,.p_internetReachableSubject_get): return Matcher.ComparisonResult.match + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .p_isInternetAvaliable_get: return 0 + case .p_isMobileData_get: return 0 + case .p_internetReachableSubject_get: return 0 + } + } + func assertionName() -> String { + switch self { + case .p_isInternetAvaliable_get: return "[get] .isInternetAvaliable" + case .p_isMobileData_get: return "[get] .isMobileData" + case .p_internetReachableSubject_get: return "[get] .internetReachableSubject" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func isInternetAvaliable(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_isInternetAvaliable_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func isMobileData(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_isMobileData_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func internetReachableSubject(getter defaultValue: CurrentValueSubject...) -> PropertyStub { + return Given(method: .p_internetReachableSubject_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static var isInternetAvaliable: Verify { return Verify(method: .p_isInternetAvaliable_get) } + public static var isMobileData: Verify { return Verify(method: .p_isMobileData_get) } + public static var internetReachableSubject: Verify { return Verify(method: .p_internetReachableSubject_get) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + } + + 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: - 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 trackScreenEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + addInvocation(.m_trackScreenEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackScreenEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, [String: Any]?) -> Void + perform?(`event`, `parameters`) + } + + open func trackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { + addInvocation(.m_trackScreenEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackScreenEvent__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`) + } + + open func trackScreenEvent(_ event: AnalyticsEvent) { + addInvocation(.m_trackScreenEvent__event(Parameter.value(`event`))) + let perform = methodPerformValue(.m_trackScreenEvent__event(Parameter.value(`event`))) as? (AnalyticsEvent) -> Void + perform?(`event`) + } + + open func trackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_trackScreenEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_trackScreenEvent__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_trackScreenEvent__eventparameters_parameters(Parameter, Parameter<[String: Any]?>) + case m_trackScreenEvent__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) + case m_trackScreenEvent__event(Parameter) + case m_trackScreenEvent__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_trackScreenEvent__eventparameters_parameters(let lhsEvent, let lhsParameters), .m_trackScreenEvent__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_trackScreenEvent__eventbiValue_biValueparameters_parameters(let lhsEvent, let lhsBivalue, let lhsParameters), .m_trackScreenEvent__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) + + case (.m_trackScreenEvent__event(let lhsEvent), .m_trackScreenEvent__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_trackScreenEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_trackScreenEvent__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_trackScreenEvent__eventparameters_parameters(p0, p1): return p0.intValue + p1.intValue + case let .m_trackScreenEvent__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 + case let .m_trackScreenEvent__event(p0): return p0.intValue + case let .m_trackScreenEvent__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_trackScreenEvent__eventparameters_parameters: return ".trackScreenEvent(_:parameters:)" + case .m_trackScreenEvent__eventbiValue_biValueparameters_parameters: return ".trackScreenEvent(_: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:)" + case .m_trackScreenEvent__event: return ".trackScreenEvent(_:)" + case .m_trackScreenEvent__eventbiValue_biValue: return ".trackScreenEvent(_: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 trackScreenEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackScreenEvent__eventparameters_parameters(`event`, `parameters`))} + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackScreenEvent__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 static func trackScreenEvent(_ event: Parameter) -> Verify { return Verify(method: .m_trackScreenEvent__event(`event`))} + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_trackScreenEvent__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 trackScreenEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__eventparameters_parameters(`event`, `parameters`), performs: perform) + } + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__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 static func trackScreenEvent(_ event: Parameter, perform: @escaping (AnalyticsEvent) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__event(`event`), performs: perform) + } + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__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: - CorePersistenceProtocol + +open class CorePersistenceProtocolMock: CorePersistenceProtocol, 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 set(userId: Int) { + addInvocation(.m_set__userId_userId(Parameter.value(`userId`))) + let perform = methodPerformValue(.m_set__userId_userId(Parameter.value(`userId`))) as? (Int) -> Void + perform?(`userId`) + } + + open func getUserID() -> Int? { + addInvocation(.m_getUserID) + let perform = methodPerformValue(.m_getUserID) as? () -> Void + perform?() + var __value: Int? = nil + do { + __value = try methodReturnValue(.m_getUserID).casted() + } catch { + // do nothing + } + return __value + } + + open func publisher() -> AnyPublisher { + addInvocation(.m_publisher) + let perform = methodPerformValue(.m_publisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_publisher).casted() + } catch { + onFatalFailure("Stub return value not specified for publisher(). Use given") + Failure("Stub return value not specified for publisher(). Use given") + } + return __value + } + + open func addToDownloadQueue(tasks: [DownloadDataTask]) { + addInvocation(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) as? ([DownloadDataTask]) -> Void + perform?(`tasks`) + } + + open func saveOfflineProgress(progress: OfflineProgress) { + addInvocation(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) + let perform = methodPerformValue(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) as? (OfflineProgress) -> Void + perform?(`progress`) + } + + open func loadProgress(for blockID: String) -> OfflineProgress? { + addInvocation(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + var __value: OfflineProgress? = nil + do { + __value = try methodReturnValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))).casted() + } catch { + // do nothing + } + return __value + } + + open func loadAllOfflineProgress() -> [OfflineProgress] { + addInvocation(.m_loadAllOfflineProgress) + let perform = methodPerformValue(.m_loadAllOfflineProgress) as? () -> Void + perform?() + var __value: [OfflineProgress] + do { + __value = try methodReturnValue(.m_loadAllOfflineProgress).casted() + } catch { + onFatalFailure("Stub return value not specified for loadAllOfflineProgress(). Use given") + Failure("Stub return value not specified for loadAllOfflineProgress(). Use given") + } + return __value + } + + open func deleteProgress(for blockID: String) { + addInvocation(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + } + + open func deleteAllProgress() { + addInvocation(.m_deleteAllProgress) + let perform = methodPerformValue(.m_deleteAllProgress) as? () -> Void + perform?() + } + + open func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) { + addInvocation(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) as? ([CourseBlock], DownloadQuality) -> Void + perform?(`blocks`, `downloadQuality`) + } + + open func nextBlockForDownloading() -> DownloadDataTask? { + addInvocation(.m_nextBlockForDownloading) + let perform = methodPerformValue(.m_nextBlockForDownloading) as? () -> Void + perform?() + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_nextBlockForDownloading).casted() + } catch { + // do nothing + } + return __value + } + + open func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) { + addInvocation(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) + let perform = methodPerformValue(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) as? (String, DownloadState, Data?) -> Void + perform?(`id`, `state`, `resumeData`) + } + + open func deleteDownloadDataTask(id: String) throws { + addInvocation(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) + let perform = methodPerformValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) as? (String) -> Void + perform?(`id`) + do { + _ = try methodReturnValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func saveDownloadDataTask(_ task: DownloadDataTask) { + addInvocation(.m_saveDownloadDataTask__task(Parameter.value(`task`))) + let perform = methodPerformValue(.m_saveDownloadDataTask__task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void + perform?(`task`) + } + + open func downloadDataTask(for blockId: String) -> DownloadDataTask? { + addInvocation(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void + perform?(`blockId`) + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))).casted() + } catch { + // do nothing + } + return __value + } + + open func getDownloadDataTasks() -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasks) + let perform = methodPerformValue(.m_getDownloadDataTasks) as? () -> Void + perform?() + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasks).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasks(). Use given") + Failure("Stub return value not specified for getDownloadDataTasks(). Use given") + } + return __value + } + + open func getDownloadDataTasksForCourse(_ courseId: String) -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + } + return __value + } + + + fileprivate enum MethodType { + case m_set__userId_userId(Parameter) + case m_getUserID + case m_publisher + case m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>) + case m_saveOfflineProgress__progress_progress(Parameter) + case m_loadProgress__for_blockID(Parameter) + case m_loadAllOfflineProgress + case m_deleteProgress__for_blockID(Parameter) + case m_deleteAllProgress + case m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>, Parameter) + case m_nextBlockForDownloading + case m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter, Parameter, Parameter) + case m_deleteDownloadDataTask__id_id(Parameter) + case m_saveDownloadDataTask__task(Parameter) + case m_downloadDataTask__for_blockId(Parameter) + case m_getDownloadDataTasks + case m_getDownloadDataTasksForCourse__courseId(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_set__userId_userId(let lhsUserid), .m_set__userId_userId(let rhsUserid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUserid, rhs: rhsUserid, with: matcher), lhsUserid, rhsUserid, "userId")) + return Matcher.ComparisonResult(results) + + case (.m_getUserID, .m_getUserID): return .match + + case (.m_publisher, .m_publisher): return .match + + case (.m_addToDownloadQueue__tasks_tasks(let lhsTasks), .m_addToDownloadQueue__tasks_tasks(let rhsTasks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTasks, rhs: rhsTasks, with: matcher), lhsTasks, rhsTasks, "tasks")) + return Matcher.ComparisonResult(results) + + case (.m_saveOfflineProgress__progress_progress(let lhsProgress), .m_saveOfflineProgress__progress_progress(let rhsProgress)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsProgress, rhs: rhsProgress, with: matcher), lhsProgress, rhsProgress, "progress")) + return Matcher.ComparisonResult(results) + + case (.m_loadProgress__for_blockID(let lhsBlockid), .m_loadProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_loadAllOfflineProgress, .m_loadAllOfflineProgress): return .match + + case (.m_deleteProgress__for_blockID(let lhsBlockid), .m_deleteProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + + case (.m_deleteAllProgress, .m_deleteAllProgress): return .match + + case (.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let lhsBlocks, let lhsDownloadquality), .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let rhsBlocks, let rhsDownloadquality)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDownloadquality, rhs: rhsDownloadquality, with: matcher), lhsDownloadquality, rhsDownloadquality, "downloadQuality")) + return Matcher.ComparisonResult(results) + + case (.m_nextBlockForDownloading, .m_nextBlockForDownloading): return .match + + case (.m_updateDownloadState__id_idstate_stateresumeData_resumeData(let lhsId, let lhsState, let lhsResumedata), .m_updateDownloadState__id_idstate_stateresumeData_resumeData(let rhsId, let rhsState, let rhsResumedata)): + 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: lhsState, rhs: rhsState, with: matcher), lhsState, rhsState, "state")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsResumedata, rhs: rhsResumedata, with: matcher), lhsResumedata, rhsResumedata, "resumeData")) + return Matcher.ComparisonResult(results) + + case (.m_deleteDownloadDataTask__id_id(let lhsId), .m_deleteDownloadDataTask__id_id(let rhsId)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "id")) + return Matcher.ComparisonResult(results) + + case (.m_saveDownloadDataTask__task(let lhsTask), .m_saveDownloadDataTask__task(let rhsTask)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "_ task")) + return Matcher.ComparisonResult(results) + + case (.m_downloadDataTask__for_blockId(let lhsBlockid), .m_downloadDataTask__for_blockId(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + return Matcher.ComparisonResult(results) + + case (.m_getDownloadDataTasks, .m_getDownloadDataTasks): return .match + + case (.m_getDownloadDataTasksForCourse__courseId(let lhsCourseid), .m_getDownloadDataTasksForCourse__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 + } + } + + func intValue() -> Int { + switch self { + case let .m_set__userId_userId(p0): return p0.intValue + case .m_getUserID: return 0 + case .m_publisher: return 0 + case let .m_addToDownloadQueue__tasks_tasks(p0): return p0.intValue + case let .m_saveOfflineProgress__progress_progress(p0): return p0.intValue + case let .m_loadProgress__for_blockID(p0): return p0.intValue + case .m_loadAllOfflineProgress: return 0 + case let .m_deleteProgress__for_blockID(p0): return p0.intValue + case .m_deleteAllProgress: return 0 + case let .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(p0, p1): return p0.intValue + p1.intValue + case .m_nextBlockForDownloading: return 0 + case let .m_updateDownloadState__id_idstate_stateresumeData_resumeData(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_deleteDownloadDataTask__id_id(p0): return p0.intValue + case let .m_saveDownloadDataTask__task(p0): return p0.intValue + case let .m_downloadDataTask__for_blockId(p0): return p0.intValue + case .m_getDownloadDataTasks: return 0 + case let .m_getDownloadDataTasksForCourse__courseId(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_set__userId_userId: return ".set(userId:)" + case .m_getUserID: return ".getUserID()" + case .m_publisher: return ".publisher()" + case .m_addToDownloadQueue__tasks_tasks: return ".addToDownloadQueue(tasks:)" + case .m_saveOfflineProgress__progress_progress: return ".saveOfflineProgress(progress:)" + case .m_loadProgress__for_blockID: return ".loadProgress(for:)" + case .m_loadAllOfflineProgress: return ".loadAllOfflineProgress()" + case .m_deleteProgress__for_blockID: return ".deleteProgress(for:)" + case .m_deleteAllProgress: return ".deleteAllProgress()" + case .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality: return ".addToDownloadQueue(blocks:downloadQuality:)" + case .m_nextBlockForDownloading: return ".nextBlockForDownloading()" + case .m_updateDownloadState__id_idstate_stateresumeData_resumeData: return ".updateDownloadState(id:state:resumeData:)" + case .m_deleteDownloadDataTask__id_id: return ".deleteDownloadDataTask(id:)" + case .m_saveDownloadDataTask__task: return ".saveDownloadDataTask(_:)" + case .m_downloadDataTask__for_blockId: return ".downloadDataTask(for:)" + case .m_getDownloadDataTasks: return ".getDownloadDataTasks()" + case .m_getDownloadDataTasksForCourse__courseId: return ".getDownloadDataTasksForCourse(_:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func getUserID(willReturn: Int?...) -> MethodStub { + return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func publisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadProgress(for blockID: Parameter, willReturn: OfflineProgress?...) -> MethodStub { + return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadAllOfflineProgress(willReturn: [OfflineProgress]...) -> MethodStub { + return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func nextBlockForDownloading(willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func downloadDataTask(for blockId: Parameter, willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasks(willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getUserID(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Int?] = [] + let given: Given = { return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Int?).self) + willProduce(stubber) + return given + } + public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func loadProgress(for blockID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [OfflineProgress?] = [] + let given: Given = { return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (OfflineProgress?).self) + willProduce(stubber) + return given + } + public static func loadAllOfflineProgress(willProduce: (Stubber<[OfflineProgress]>) -> Void) -> MethodStub { + let willReturn: [[OfflineProgress]] = [] + let given: Given = { return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([OfflineProgress]).self) + willProduce(stubber) + return given + } + public static func nextBlockForDownloading(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func downloadDataTask(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func deleteDownloadDataTask(id: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func deleteDownloadDataTask(id: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func set(userId: Parameter) -> Verify { return Verify(method: .m_set__userId_userId(`userId`))} + public static func getUserID() -> Verify { return Verify(method: .m_getUserID)} + public static func publisher() -> Verify { return Verify(method: .m_publisher)} + public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>) -> Verify { return Verify(method: .m_addToDownloadQueue__tasks_tasks(`tasks`))} + public static func saveOfflineProgress(progress: Parameter) -> Verify { return Verify(method: .m_saveOfflineProgress__progress_progress(`progress`))} + public static func loadProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_loadProgress__for_blockID(`blockID`))} + public static func loadAllOfflineProgress() -> Verify { return Verify(method: .m_loadAllOfflineProgress)} + public static func deleteProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_deleteProgress__for_blockID(`blockID`))} + public static func deleteAllProgress() -> Verify { return Verify(method: .m_deleteAllProgress)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`))} + public static func nextBlockForDownloading() -> Verify { return Verify(method: .m_nextBlockForDownloading)} + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter) -> Verify { return Verify(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`))} + public static func deleteDownloadDataTask(id: Parameter) -> Verify { return Verify(method: .m_deleteDownloadDataTask__id_id(`id`))} + public static func saveDownloadDataTask(_ task: Parameter) -> Verify { return Verify(method: .m_saveDownloadDataTask__task(`task`))} + public static func downloadDataTask(for blockId: Parameter) -> Verify { return Verify(method: .m_downloadDataTask__for_blockId(`blockId`))} + public static func getDownloadDataTasks() -> Verify { return Verify(method: .m_getDownloadDataTasks)} + public static func getDownloadDataTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func set(userId: Parameter, perform: @escaping (Int) -> Void) -> Perform { + return Perform(method: .m_set__userId_userId(`userId`), performs: perform) + } + public static func getUserID(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getUserID, performs: perform) + } + public static func publisher(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_publisher, performs: perform) + } + public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>, perform: @escaping ([DownloadDataTask]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__tasks_tasks(`tasks`), performs: perform) + } + public static func saveOfflineProgress(progress: Parameter, perform: @escaping (OfflineProgress) -> Void) -> Perform { + return Perform(method: .m_saveOfflineProgress__progress_progress(`progress`), performs: perform) + } + public static func loadProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_loadProgress__for_blockID(`blockID`), performs: perform) + } + public static func loadAllOfflineProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_loadAllOfflineProgress, performs: perform) + } + public static func deleteProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteProgress__for_blockID(`blockID`), performs: perform) + } + public static func deleteAllProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllProgress, performs: perform) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter, perform: @escaping ([CourseBlock], DownloadQuality) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`), performs: perform) + } + public static func nextBlockForDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_nextBlockForDownloading, performs: perform) + } + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter, perform: @escaping (String, DownloadState, Data?) -> Void) -> Perform { + return Perform(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`), performs: perform) + } + public static func deleteDownloadDataTask(id: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteDownloadDataTask__id_id(`id`), performs: perform) + } + public static func saveDownloadDataTask(_ task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { + return Perform(method: .m_saveDownloadDataTask__task(`task`), performs: perform) + } + public static func downloadDataTask(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_downloadDataTask__for_blockId(`blockId`), performs: perform) + } + public static func getDownloadDataTasks(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasks, performs: perform) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), 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: - CoreStorage + +open class CoreStorageMock: CoreStorage, 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 accessToken: String? { + get { invocations.append(.p_accessToken_get); return __p_accessToken ?? optionalGivenGetterValue(.p_accessToken_get, "CoreStorageMock - stub value for accessToken was not defined") } + set { invocations.append(.p_accessToken_set(.value(newValue))); __p_accessToken = newValue } + } + private var __p_accessToken: (String)? + + public var refreshToken: String? { + get { invocations.append(.p_refreshToken_get); return __p_refreshToken ?? optionalGivenGetterValue(.p_refreshToken_get, "CoreStorageMock - stub value for refreshToken was not defined") } + set { invocations.append(.p_refreshToken_set(.value(newValue))); __p_refreshToken = newValue } + } + private var __p_refreshToken: (String)? + + public var pushToken: String? { + get { invocations.append(.p_pushToken_get); return __p_pushToken ?? optionalGivenGetterValue(.p_pushToken_get, "CoreStorageMock - stub value for pushToken was not defined") } + set { invocations.append(.p_pushToken_set(.value(newValue))); __p_pushToken = newValue } + } + private var __p_pushToken: (String)? + + public var appleSignFullName: String? { + get { invocations.append(.p_appleSignFullName_get); return __p_appleSignFullName ?? optionalGivenGetterValue(.p_appleSignFullName_get, "CoreStorageMock - stub value for appleSignFullName was not defined") } + set { invocations.append(.p_appleSignFullName_set(.value(newValue))); __p_appleSignFullName = newValue } + } + private var __p_appleSignFullName: (String)? + + public var appleSignEmail: String? { + get { invocations.append(.p_appleSignEmail_get); return __p_appleSignEmail ?? optionalGivenGetterValue(.p_appleSignEmail_get, "CoreStorageMock - stub value for appleSignEmail was not defined") } + set { invocations.append(.p_appleSignEmail_set(.value(newValue))); __p_appleSignEmail = newValue } + } + private var __p_appleSignEmail: (String)? + + public var cookiesDate: Date? { + get { invocations.append(.p_cookiesDate_get); return __p_cookiesDate ?? optionalGivenGetterValue(.p_cookiesDate_get, "CoreStorageMock - stub value for cookiesDate was not defined") } + set { invocations.append(.p_cookiesDate_set(.value(newValue))); __p_cookiesDate = newValue } + } + private var __p_cookiesDate: (Date)? + + public var reviewLastShownVersion: String? { + get { invocations.append(.p_reviewLastShownVersion_get); return __p_reviewLastShownVersion ?? optionalGivenGetterValue(.p_reviewLastShownVersion_get, "CoreStorageMock - stub value for reviewLastShownVersion was not defined") } + set { invocations.append(.p_reviewLastShownVersion_set(.value(newValue))); __p_reviewLastShownVersion = newValue } + } + private var __p_reviewLastShownVersion: (String)? + + public var lastReviewDate: Date? { + get { invocations.append(.p_lastReviewDate_get); return __p_lastReviewDate ?? optionalGivenGetterValue(.p_lastReviewDate_get, "CoreStorageMock - stub value for lastReviewDate was not defined") } + set { invocations.append(.p_lastReviewDate_set(.value(newValue))); __p_lastReviewDate = newValue } + } + private var __p_lastReviewDate: (Date)? + + public var user: DataLayer.User? { + get { invocations.append(.p_user_get); return __p_user ?? optionalGivenGetterValue(.p_user_get, "CoreStorageMock - stub value for user was not defined") } + set { invocations.append(.p_user_set(.value(newValue))); __p_user = newValue } + } + private var __p_user: (DataLayer.User)? + + public var userSettings: UserSettings? { + get { invocations.append(.p_userSettings_get); return __p_userSettings ?? optionalGivenGetterValue(.p_userSettings_get, "CoreStorageMock - stub value for userSettings was not defined") } + set { invocations.append(.p_userSettings_set(.value(newValue))); __p_userSettings = newValue } + } + private var __p_userSettings: (UserSettings)? + + public var resetAppSupportDirectoryUserData: Bool? { + get { invocations.append(.p_resetAppSupportDirectoryUserData_get); return __p_resetAppSupportDirectoryUserData ?? optionalGivenGetterValue(.p_resetAppSupportDirectoryUserData_get, "CoreStorageMock - stub value for resetAppSupportDirectoryUserData was not defined") } + set { invocations.append(.p_resetAppSupportDirectoryUserData_set(.value(newValue))); __p_resetAppSupportDirectoryUserData = newValue } + } + private var __p_resetAppSupportDirectoryUserData: (Bool)? + + public var useRelativeDates: Bool { + get { invocations.append(.p_useRelativeDates_get); return __p_useRelativeDates ?? givenGetterValue(.p_useRelativeDates_get, "CoreStorageMock - stub value for useRelativeDates was not defined") } + set { invocations.append(.p_useRelativeDates_set(.value(newValue))); __p_useRelativeDates = newValue } + } + private var __p_useRelativeDates: (Bool)? + + + + + + open func clear() { + addInvocation(.m_clear) + let perform = methodPerformValue(.m_clear) as? () -> Void + perform?() + } + + + fileprivate enum MethodType { + case m_clear + case p_accessToken_get + case p_accessToken_set(Parameter) + case p_refreshToken_get + case p_refreshToken_set(Parameter) + case p_pushToken_get + case p_pushToken_set(Parameter) + case p_appleSignFullName_get + case p_appleSignFullName_set(Parameter) + case p_appleSignEmail_get + case p_appleSignEmail_set(Parameter) + case p_cookiesDate_get + case p_cookiesDate_set(Parameter) + case p_reviewLastShownVersion_get + case p_reviewLastShownVersion_set(Parameter) + case p_lastReviewDate_get + case p_lastReviewDate_set(Parameter) + case p_user_get + case p_user_set(Parameter) + case p_userSettings_get + case p_userSettings_set(Parameter) + case p_resetAppSupportDirectoryUserData_get + case p_resetAppSupportDirectoryUserData_set(Parameter) + case p_useRelativeDates_get + case p_useRelativeDates_set(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_clear, .m_clear): return .match + case (.p_accessToken_get,.p_accessToken_get): return Matcher.ComparisonResult.match + case (.p_accessToken_set(let left),.p_accessToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_refreshToken_get,.p_refreshToken_get): return Matcher.ComparisonResult.match + case (.p_refreshToken_set(let left),.p_refreshToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_pushToken_get,.p_pushToken_get): return Matcher.ComparisonResult.match + case (.p_pushToken_set(let left),.p_pushToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_appleSignFullName_get,.p_appleSignFullName_get): return Matcher.ComparisonResult.match + case (.p_appleSignFullName_set(let left),.p_appleSignFullName_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_appleSignEmail_get,.p_appleSignEmail_get): return Matcher.ComparisonResult.match + case (.p_appleSignEmail_set(let left),.p_appleSignEmail_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_cookiesDate_get,.p_cookiesDate_get): return Matcher.ComparisonResult.match + case (.p_cookiesDate_set(let left),.p_cookiesDate_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_reviewLastShownVersion_get,.p_reviewLastShownVersion_get): return Matcher.ComparisonResult.match + case (.p_reviewLastShownVersion_set(let left),.p_reviewLastShownVersion_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_lastReviewDate_get,.p_lastReviewDate_get): return Matcher.ComparisonResult.match + case (.p_lastReviewDate_set(let left),.p_lastReviewDate_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_user_get,.p_user_get): return Matcher.ComparisonResult.match + case (.p_user_set(let left),.p_user_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_userSettings_get,.p_userSettings_get): return Matcher.ComparisonResult.match + case (.p_userSettings_set(let left),.p_userSettings_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_resetAppSupportDirectoryUserData_get,.p_resetAppSupportDirectoryUserData_get): return Matcher.ComparisonResult.match + case (.p_resetAppSupportDirectoryUserData_set(let left),.p_resetAppSupportDirectoryUserData_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_useRelativeDates_get,.p_useRelativeDates_get): return Matcher.ComparisonResult.match + case (.p_useRelativeDates_set(let left),.p_useRelativeDates_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 .m_clear: return 0 + case .p_accessToken_get: return 0 + case .p_accessToken_set(let newValue): return newValue.intValue + case .p_refreshToken_get: return 0 + case .p_refreshToken_set(let newValue): return newValue.intValue + case .p_pushToken_get: return 0 + case .p_pushToken_set(let newValue): return newValue.intValue + case .p_appleSignFullName_get: return 0 + case .p_appleSignFullName_set(let newValue): return newValue.intValue + case .p_appleSignEmail_get: return 0 + case .p_appleSignEmail_set(let newValue): return newValue.intValue + case .p_cookiesDate_get: return 0 + case .p_cookiesDate_set(let newValue): return newValue.intValue + case .p_reviewLastShownVersion_get: return 0 + case .p_reviewLastShownVersion_set(let newValue): return newValue.intValue + case .p_lastReviewDate_get: return 0 + case .p_lastReviewDate_set(let newValue): return newValue.intValue + case .p_user_get: return 0 + case .p_user_set(let newValue): return newValue.intValue + case .p_userSettings_get: return 0 + case .p_userSettings_set(let newValue): return newValue.intValue + case .p_resetAppSupportDirectoryUserData_get: return 0 + case .p_resetAppSupportDirectoryUserData_set(let newValue): return newValue.intValue + case .p_useRelativeDates_get: return 0 + case .p_useRelativeDates_set(let newValue): return newValue.intValue + } + } + func assertionName() -> String { + switch self { + case .m_clear: return ".clear()" + case .p_accessToken_get: return "[get] .accessToken" + case .p_accessToken_set: return "[set] .accessToken" + case .p_refreshToken_get: return "[get] .refreshToken" + case .p_refreshToken_set: return "[set] .refreshToken" + case .p_pushToken_get: return "[get] .pushToken" + case .p_pushToken_set: return "[set] .pushToken" + case .p_appleSignFullName_get: return "[get] .appleSignFullName" + case .p_appleSignFullName_set: return "[set] .appleSignFullName" + case .p_appleSignEmail_get: return "[get] .appleSignEmail" + case .p_appleSignEmail_set: return "[set] .appleSignEmail" + case .p_cookiesDate_get: return "[get] .cookiesDate" + case .p_cookiesDate_set: return "[set] .cookiesDate" + case .p_reviewLastShownVersion_get: return "[get] .reviewLastShownVersion" + case .p_reviewLastShownVersion_set: return "[set] .reviewLastShownVersion" + case .p_lastReviewDate_get: return "[get] .lastReviewDate" + case .p_lastReviewDate_set: return "[set] .lastReviewDate" + case .p_user_get: return "[get] .user" + case .p_user_set: return "[set] .user" + case .p_userSettings_get: return "[get] .userSettings" + case .p_userSettings_set: return "[set] .userSettings" + case .p_resetAppSupportDirectoryUserData_get: return "[get] .resetAppSupportDirectoryUserData" + case .p_resetAppSupportDirectoryUserData_set: return "[set] .resetAppSupportDirectoryUserData" + case .p_useRelativeDates_get: return "[get] .useRelativeDates" + case .p_useRelativeDates_set: return "[set] .useRelativeDates" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func accessToken(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_accessToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func refreshToken(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_refreshToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func pushToken(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_pushToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appleSignFullName(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_appleSignFullName_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appleSignEmail(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_appleSignEmail_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func cookiesDate(getter defaultValue: Date?...) -> PropertyStub { + return Given(method: .p_cookiesDate_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func reviewLastShownVersion(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_reviewLastShownVersion_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func lastReviewDate(getter defaultValue: Date?...) -> PropertyStub { + return Given(method: .p_lastReviewDate_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func user(getter defaultValue: DataLayer.User?...) -> PropertyStub { + return Given(method: .p_user_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func userSettings(getter defaultValue: UserSettings?...) -> PropertyStub { + return Given(method: .p_userSettings_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func resetAppSupportDirectoryUserData(getter defaultValue: Bool?...) -> PropertyStub { + return Given(method: .p_resetAppSupportDirectoryUserData_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func useRelativeDates(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_useRelativeDates_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func clear() -> Verify { return Verify(method: .m_clear)} + public static var accessToken: Verify { return Verify(method: .p_accessToken_get) } + public static func accessToken(set newValue: Parameter) -> Verify { return Verify(method: .p_accessToken_set(newValue)) } + public static var refreshToken: Verify { return Verify(method: .p_refreshToken_get) } + public static func refreshToken(set newValue: Parameter) -> Verify { return Verify(method: .p_refreshToken_set(newValue)) } + public static var pushToken: Verify { return Verify(method: .p_pushToken_get) } + public static func pushToken(set newValue: Parameter) -> Verify { return Verify(method: .p_pushToken_set(newValue)) } + public static var appleSignFullName: Verify { return Verify(method: .p_appleSignFullName_get) } + public static func appleSignFullName(set newValue: Parameter) -> Verify { return Verify(method: .p_appleSignFullName_set(newValue)) } + public static var appleSignEmail: Verify { return Verify(method: .p_appleSignEmail_get) } + public static func appleSignEmail(set newValue: Parameter) -> Verify { return Verify(method: .p_appleSignEmail_set(newValue)) } + public static var cookiesDate: Verify { return Verify(method: .p_cookiesDate_get) } + public static func cookiesDate(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesDate_set(newValue)) } + public static var reviewLastShownVersion: Verify { return Verify(method: .p_reviewLastShownVersion_get) } + public static func reviewLastShownVersion(set newValue: Parameter) -> Verify { return Verify(method: .p_reviewLastShownVersion_set(newValue)) } + public static var lastReviewDate: Verify { return Verify(method: .p_lastReviewDate_get) } + public static func lastReviewDate(set newValue: Parameter) -> Verify { return Verify(method: .p_lastReviewDate_set(newValue)) } + public static var user: Verify { return Verify(method: .p_user_get) } + public static func user(set newValue: Parameter) -> Verify { return Verify(method: .p_user_set(newValue)) } + public static var userSettings: Verify { return Verify(method: .p_userSettings_get) } + public static func userSettings(set newValue: Parameter) -> Verify { return Verify(method: .p_userSettings_set(newValue)) } + public static var resetAppSupportDirectoryUserData: Verify { return Verify(method: .p_resetAppSupportDirectoryUserData_get) } + public static func resetAppSupportDirectoryUserData(set newValue: Parameter) -> Verify { return Verify(method: .p_resetAppSupportDirectoryUserData_set(newValue)) } + public static var useRelativeDates: Verify { return Verify(method: .p_useRelativeDates_get) } + public static func useRelativeDates(set newValue: Parameter) -> Verify { return Verify(method: .p_useRelativeDates_set(newValue)) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func clear(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_clear, 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 { + 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 currentDownloadTask: DownloadDataTask? { + get { invocations.append(.p_currentDownloadTask_get); return __p_currentDownloadTask ?? optionalGivenGetterValue(.p_currentDownloadTask_get, "DownloadManagerProtocolMock - stub value for currentDownloadTask was not defined") } + } + private var __p_currentDownloadTask: (DownloadDataTask)? + + + + + + open func publisher() -> AnyPublisher { + addInvocation(.m_publisher) + let perform = methodPerformValue(.m_publisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_publisher).casted() + } catch { + onFatalFailure("Stub return value not specified for publisher(). Use given") + Failure("Stub return value not specified for publisher(). Use given") + } + return __value + } + + open func eventPublisher() -> AnyPublisher { + addInvocation(.m_eventPublisher) + let perform = methodPerformValue(.m_eventPublisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_eventPublisher).casted() + } catch { + onFatalFailure("Stub return value not specified for eventPublisher(). Use given") + Failure("Stub return value not specified for eventPublisher(). Use given") + } + 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 + perform?() + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadTasks).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadTasks(). Use given") + Failure("Stub return value not specified for getDownloadTasks(). Use given") + } + return __value + } + + open func getDownloadTasksForCourse(_ courseId: String) -> [DownloadDataTask] { + addInvocation(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadTasksForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadTasksForCourse(_ courseId: String). Use given") + } + return __value + } + + open func cancelDownloading(courseId: String, blocks: [CourseBlock]) throws { + addInvocation(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))) as? (String, [CourseBlock]) -> Void + perform?(`courseId`, `blocks`) + do { + _ = try methodReturnValue(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func cancelDownloading(task: DownloadDataTask) throws { + addInvocation(.m_cancelDownloading__task_task(Parameter.value(`task`))) + let perform = methodPerformValue(.m_cancelDownloading__task_task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void + perform?(`task`) + do { + _ = try methodReturnValue(.m_cancelDownloading__task_task(Parameter.value(`task`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func cancelDownloading(courseId: String) throws { + addInvocation(.m_cancelDownloading__courseId_courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_cancelDownloading__courseId_courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + do { + _ = try methodReturnValue(.m_cancelDownloading__courseId_courseId(Parameter.value(`courseId`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + 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 + perform?(`blocks`) + } + + open func deleteAllFiles() { + addInvocation(.m_deleteAllFiles) + let perform = methodPerformValue(.m_deleteAllFiles) as? () -> Void + perform?() + } + + open func fileUrl(for blockId: String) -> URL? { + addInvocation(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void + perform?(`blockId`) + var __value: URL? = nil + do { + __value = try methodReturnValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))).casted() + } catch { + // do nothing + } + return __value + } + + open func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] { + addInvocation(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) + let perform = methodPerformValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) as? ([CourseSequential]) -> Void + perform?(`sequentials`) + var __value: [CourseSequential] + do { + __value = try methodReturnValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))).casted() + } catch { + onFatalFailure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + Failure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + } + return __value + } + + open func resumeDownloading() throws { + addInvocation(.m_resumeDownloading) + let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void + perform?() + do { + _ = try methodReturnValue(.m_resumeDownloading).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func isLargeVideosSize(blocks: [CourseBlock]) -> Bool { + addInvocation(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + var __value: Bool + do { + __value = try methodReturnValue(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() + } catch { + onFatalFailure("Stub return value not specified for isLargeVideosSize(blocks: [CourseBlock]). Use given") + Failure("Stub return value not specified for isLargeVideosSize(blocks: [CourseBlock]). Use given") + } + return __value + } + + open func removeAppSupportDirectoryUnusedContent() { + addInvocation(.m_removeAppSupportDirectoryUnusedContent) + let perform = methodPerformValue(.m_removeAppSupportDirectoryUnusedContent) as? () -> Void + perform?() + } + + + 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_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>) + case m_resumeDownloading + case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) + case m_removeAppSupportDirectoryUnusedContent + case p_currentDownloadTask_get + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_publisher, .m_publisher): return .match + + case (.m_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)): + 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_cancelDownloading__courseId_courseIdblocks_blocks(let lhsCourseid, let lhsBlocks), .m_cancelDownloading__courseId_courseIdblocks_blocks(let rhsCourseid, let rhsBlocks)): + 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: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_cancelDownloading__task_task(let lhsTask), .m_cancelDownloading__task_task(let rhsTask)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "task")) + return Matcher.ComparisonResult(results) + + case (.m_cancelDownloading__courseId_courseId(let lhsCourseid), .m_cancelDownloading__courseId_courseId(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + return Matcher.ComparisonResult(results) + + case (.m_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")) + return Matcher.ComparisonResult(results) + + case (.m_deleteAllFiles, .m_deleteAllFiles): return .match + + case (.m_fileUrl__for_blockId(let lhsBlockid), .m_fileUrl__for_blockId(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + return Matcher.ComparisonResult(results) + + case (.m_updateUnzippedFileSize__for_sequentials(let lhsSequentials), .m_updateUnzippedFileSize__for_sequentials(let rhsSequentials)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSequentials, rhs: rhsSequentials, with: matcher), lhsSequentials, rhsSequentials, "for sequentials")) + 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_removeAppSupportDirectoryUnusedContent, .m_removeAppSupportDirectoryUnusedContent): return .match + case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match + default: return .none + } + } + + func intValue() -> Int { + 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_updateUnzippedFileSize__for_sequentials(p0): return p0.intValue + case .m_resumeDownloading: return 0 + case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue + case .m_removeAppSupportDirectoryUnusedContent: return 0 + case .p_currentDownloadTask_get: return 0 + } + } + func assertionName() -> String { + 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_updateUnzippedFileSize__for_sequentials: return ".updateUnzippedFileSize(for:)" + case .m_resumeDownloading: return ".resumeDownloading()" + case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" + case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" + case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func currentDownloadTask(getter defaultValue: DownloadDataTask?...) -> PropertyStub { + return Given(method: .p_currentDownloadTask_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + public static func publisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func eventPublisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadTasks(willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { + return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willReturn: [CourseSequential]...) -> MethodStub { + return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willReturn: Bool...) -> MethodStub { + return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func eventPublisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func getDownloadTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func fileUrl(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [URL?] = [] + let given: Given = { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (URL?).self) + willProduce(stubber) + return given + } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willProduce: (Stubber<[CourseSequential]>) -> Void) -> MethodStub { + let willReturn: [[CourseSequential]] = [] + let given: Given = { return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseSequential]).self) + willProduce(stubber) + return given + } + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Bool] = [] + let given: Given = { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Bool).self) + willProduce(stubber) + return given + } + 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) })) + } + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func cancelDownloading(task: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelDownloading__task_task(`task`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func cancelDownloading(task: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_cancelDownloading__task_task(`task`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func cancelDownloading(courseId: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelDownloading__courseId_courseId(`courseId`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func cancelDownloading(courseId: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_cancelDownloading__courseId_courseId(`courseId`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func cancelAllDownloading(willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelAllDownloading, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func cancelAllDownloading(willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + 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 + } + public static func resumeDownloading(willThrow: Error...) -> MethodStub { + return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func resumeDownloading(willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + 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 updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>) -> Verify { return Verify(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`))} + 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 func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} + public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func publisher(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_publisher, performs: perform) + } + 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) + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadTasksForCourse__courseId(`courseId`), performs: perform) + } + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, perform: @escaping (String, [CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), performs: perform) + } + public static func cancelDownloading(task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__task_task(`task`), performs: perform) + } + public static func cancelDownloading(courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__courseId_courseId(`courseId`), performs: perform) + } + public static func 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) + } + public static func deleteAllFiles(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllFiles, performs: perform) + } + public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) + } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, perform: @escaping ([CourseSequential]) -> Void) -> Perform { + return Perform(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), 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 removeAppSupportDirectoryUnusedContent(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_removeAppSupportDirectoryUnusedContent, 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: - OfflineSyncInteractorProtocol + +open class OfflineSyncInteractorProtocolMock: OfflineSyncInteractorProtocol, 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 submitOfflineProgress(courseID: String, blockID: String, data: String) throws -> Bool { + addInvocation(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) + let perform = methodPerformValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) as? (String, String, String) -> Void + perform?(`courseID`, `blockID`, `data`) + var __value: Bool + do { + __value = try methodReturnValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + Failure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + } catch { + throw error + } + return __value + } + + + fileprivate enum MethodType { + case m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter, Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let lhsCourseid, let lhsBlockid, let lhsData), .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let rhsCourseid, let rhsBlockid, let rhsData)): + 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: lhsData, rhs: rhsData, with: matcher), lhsData, rhsData, "data")) + return Matcher.ComparisonResult(results) + } + } + + func intValue() -> Int { + switch self { + case let .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + } + } + func assertionName() -> String { + switch self { + case .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data: return ".submitOfflineProgress(courseID:blockID:data:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willReturn: Bool...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Bool).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter) -> Verify { return Verify(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { + return Perform(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), 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) + } + } + + 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/Core/CoreTests/DownloadManager/DownloadManagerTests.swift b/Core/CoreTests/DownloadManager/DownloadManagerTests.swift new file mode 100644 index 000000000..1b6d03b62 --- /dev/null +++ b/Core/CoreTests/DownloadManager/DownloadManagerTests.swift @@ -0,0 +1,328 @@ +// +// DownloadManagerTests.swift +// Core +// +// Created by Ivan Stepanok on 22.10.2024. +// + +import XCTest +import SwiftyMocky +@testable import Core + +final class DownloadManagerTests: XCTestCase { + + var persistence: CorePersistenceProtocolMock! + var storage: CoreStorageMock! + var connectivity: ConnectivityProtocolMock! + + override func setUp() { + super.setUp() + persistence = CorePersistenceProtocolMock() + storage = CoreStorageMock() + connectivity = ConnectivityProtocolMock() + } + + // MARK: - Test Add to Queue + + func testAddToDownloadQueue_WhenWiFiOnlyAndOnWiFi_ShouldAddToQueue() async throws { + // Given + Given(connectivity, .isInternetAvaliable(getter: true)) + Given(connectivity, .internetReachableSubject(getter: .init(.reachable))) + Given(connectivity, .isMobileData(getter: false)) + + let downloadManager = DownloadManager( + persistence: persistence, + appStorage: storage, + connectivity: connectivity + ) + + Given(storage, .userSettings(getter: UserSettings( + wifiOnly: true, + streamingQuality: .auto, + downloadQuality: .auto + ))) + + let blocks = [createMockCourseBlock()] + + // When + try await downloadManager.addToDownloadQueue(blocks: blocks) + + // Then + Verify(persistence, 1, .addToDownloadQueue(blocks: .value(blocks), downloadQuality: .value(.auto))) + } + + func testAddToDownloadQueue_WhenWiFiOnlyAndOnMobileData_ShouldThrowError() async { + // Given + Given(storage, .userSettings(getter: UserSettings( + wifiOnly: true, + streamingQuality: .auto, + downloadQuality: .auto + ))) + Given(connectivity, .isInternetAvaliable(getter: true)) + Given(connectivity, .isMobileData(getter: true)) + + let downloadManager = DownloadManager( + persistence: persistence, + appStorage: storage, + connectivity: connectivity + ) + + let blocks = [createMockCourseBlock()] + + // When/Then + do { + try await downloadManager.addToDownloadQueue(blocks: blocks) + XCTFail("Should throw NoWiFiError") + } catch is NoWiFiError { + // Success + Verify(persistence, 0, .addToDownloadQueue(blocks: .any, downloadQuality: .value(.auto))) + } catch { + XCTFail("Unexpected error: \(error)") + } + } + + // MARK: - Test New Download + + func testNewDownload_WhenTaskAvailable_ShouldStartDownloading() async throws { + // Given + let mockTask = createMockDownloadTask() + Given(persistence, .getDownloadDataTasks(willReturn: [mockTask])) + Given(persistence, .nextBlockForDownloading(willReturn: mockTask)) + Given(connectivity, .isInternetAvaliable(getter: true)) + Given(connectivity, .isMobileData(getter: false)) + + let downloadManager = DownloadManager( + persistence: persistence, + appStorage: storage, + connectivity: connectivity + ) + + // When + try await downloadManager.resumeDownloading() + + // Then + Verify(persistence, 2, .nextBlockForDownloading()) + XCTAssertEqual(downloadManager.currentDownloadTask?.id, mockTask.id) + } + + // MARK: - Test Cancel Downloads + + func testCancelDownloading_ForSpecificTask_ShouldRemoveFileAndTask() async throws { + // Given + let task = createMockDownloadTask() + Given(connectivity, .isInternetAvaliable(getter: true)) + Given(connectivity, .isMobileData(getter: false)) + Given(persistence, .deleteDownloadDataTask(id: .value(task.id), willProduce: { _ in })) + + let downloadManager = DownloadManager( + persistence: persistence, + appStorage: storage, + connectivity: connectivity + ) + + // When + try await downloadManager.cancelDownloading(task: task) + + // Then + Verify(persistence, 1, .deleteDownloadDataTask(id: .value(task.id))) + } + + func testCancelDownloading_ForCourse_ShouldCancelAllTasksForCourse() async throws { + // Given + let courseId = "course123" + let task = createMockDownloadTask(courseId: courseId) + let tasks = [task] + + Given(connectivity, .isInternetAvaliable(getter: true)) + Given(connectivity, .isMobileData(getter: false)) + Given(persistence, .getDownloadDataTasksForCourse(.value(courseId), willReturn: tasks)) + Given(persistence, .deleteDownloadDataTask(id: .value(task.id), willProduce: { _ in })) + + let downloadManager = DownloadManager( + persistence: persistence, + appStorage: storage, + connectivity: connectivity + ) + + // When + try await downloadManager.cancelDownloading(courseId: courseId) + + // Then + Verify(persistence, 1, .getDownloadDataTasksForCourse(.value(courseId))) + Verify(persistence, 1, .deleteDownloadDataTask(id: .value(task.id))) + } + + // MARK: - Test File Management + + func testDeleteFile_ShouldRemoveFileAndTask() async { + // Given + let block = createMockCourseBlock() + Given(connectivity, .isInternetAvaliable(getter: true)) + Given(connectivity, .isMobileData(getter: false)) + Given(persistence, .deleteDownloadDataTask(id: .value(block.id), willProduce: { _ in })) + + let downloadManager = DownloadManager( + persistence: persistence, + appStorage: storage, + connectivity: connectivity + ) + + // When + await downloadManager.deleteFile(blocks: [block]) + + // Then + Verify(persistence, 1, .deleteDownloadDataTask(id: .value(block.id))) + } + + func testFileUrl_ForFinishedTask_ShouldReturnCorrectUrl() { + // Given + let task = createMockDownloadTask(state: .finished) + let mockUser = DataLayer.User( + id: 1, + username: "test", + email: "test@test.com", + name: "Test User" + ) + + Given(storage, .user(getter: mockUser)) + Given(connectivity, .isInternetAvaliable(getter: true)) + Given(connectivity, .isMobileData(getter: false)) + Given(persistence, .downloadDataTask(for: .value(task.id), willReturn: task)) + + let downloadManager = DownloadManager( + persistence: persistence, + appStorage: storage, + connectivity: connectivity + ) + + // When + let url = downloadManager.fileUrl(for: task.id) + + // Then + XCTAssertNotNil(url) + Verify(persistence, 1, .downloadDataTask(for: .value(task.id))) + XCTAssertEqual(url?.lastPathComponent, task.fileName) + } + + // MARK: - Test Video Size Calculation + + func testIsLargeVideosSize_WhenOver1GB_ShouldReturnTrue() { + // Given + let blocks = [createMockCourseBlock(videoSize: 1_200_000_000)] // 1.2 GB + Given(connectivity, .isInternetAvaliable(getter: true)) + Given(connectivity, .isMobileData(getter: false)) + + let downloadManager = DownloadManager( + persistence: persistence, + appStorage: storage, + connectivity: connectivity + ) + + // When + let isLarge = downloadManager.isLargeVideosSize(blocks: blocks) + + // Then + XCTAssertTrue(isLarge) + } + + func testIsLargeVideosSize_WhenUnder1GB_ShouldReturnFalse() { + // Given + let blocks = [createMockCourseBlock(videoSize: 500_000_000)] // 500 MB + Given(connectivity, .isInternetAvaliable(getter: true)) + Given(connectivity, .isMobileData(getter: false)) + + let downloadManager = DownloadManager( + persistence: persistence, + appStorage: storage, + connectivity: connectivity + ) + + // When + let isLarge = downloadManager.isLargeVideosSize(blocks: blocks) + + // Then + XCTAssertFalse(isLarge) + } + + // MARK: - Test Download Tasks Retrieval + + func testGetDownloadTasks_ShouldReturnAllTasks() async { + // Given + let expectedTasks = [ + createMockDownloadTask(id: "1"), + createMockDownloadTask(id: "2") + ] + + Given(connectivity, .isInternetAvaliable(getter: true)) + Given(connectivity, .isMobileData(getter: false)) + Given(persistence, .getDownloadDataTasks(willReturn: expectedTasks)) + + let downloadManager = DownloadManager( + persistence: persistence, + appStorage: storage, + connectivity: connectivity + ) + + // When + let tasks = await downloadManager.getDownloadTasks() + + // Then + Verify(persistence, 1, .getDownloadDataTasks()) + XCTAssertEqual(tasks.count, expectedTasks.count) + XCTAssertEqual(tasks[0].id, expectedTasks[0].id) + XCTAssertEqual(tasks[1].id, expectedTasks[1].id) + } + + // MARK: - Helper Methods + + private func createMockDownloadTask( + id: String = "test123", + courseId: String = "course123", + state: DownloadState = .waiting + ) -> DownloadDataTask { + DownloadDataTask( + id: id, + blockId: "block123", + courseId: courseId, + userId: 1, + url: "https://test.com/video.mp4", + fileName: "video.mp4", + displayName: "Test Video", + progress: 0, + resumeData: nil, + state: state, + type: .video, + fileSize: 1000, + lastModified: "2024-01-01" + ) + } + + private func createMockCourseBlock(videoSize: Int = 1000) -> CourseBlock { + CourseBlock( + blockId: "block123", + id: "test123", + courseId: "course123", + graded: false, + due: nil, + completion: 0, + type: .video, + displayName: "Test Video", + studentUrl: "https://test.com", + webUrl: "https://test.com", + encodedVideo: CourseBlockEncodedVideo( + fallback: CourseBlockVideo( + url: "https://test.com/video.mp4", + fileSize: videoSize, + streamPriority: 1 + ), + youtube: nil, + desktopMP4: nil, + mobileHigh: nil, + mobileLow: nil, + hls: nil + ), + multiDevice: true, + offlineDownload: nil + ) + } +} diff --git a/Core/Mockfile b/Core/Mockfile new file mode 100644 index 000000000..86cfe49c3 --- /dev/null +++ b/Core/Mockfile @@ -0,0 +1,16 @@ +sourceryCommand: mint run krzysztofzablocki/Sourcery@2.1.2 sourcery +sourceryTemplate: ../MockTemplate.swifttemplate +unit.tests.mock: + sources: + include: + - ./../Core + - ./Core + exclude: [] + output: ./CoreTests/CoreMock.generated.swift + targets: + - MyAppUnitTests + import: + - Core + - Foundation + - SwiftUI + - Combine \ No newline at end of file diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index f3618b378..ca2964927 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -77,24 +77,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { } @discardableResult - open func login(ssoToken: String) async throws -> Core.User { - addInvocation(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) - let perform = methodPerformValue(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) as? (String) -> Void - perform?(`ssoToken`) - var __value: User - do { - __value = try methodReturnValue(.m_login__SSO__username_password(Parameter.value(ssoToken))).casted() - } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for login(username: String, password: String). Use given") - Failure("Stub return value not specified for login(username: String, password: String). Use given") - } catch { - throw error - } - return __value - } - - @discardableResult - open func login(externalToken: String, backend: String) throws -> User { + open func login(externalToken: String, backend: String) throws -> User { addInvocation(.m_login__externalToken_externalTokenbackend_backend(Parameter.value(`externalToken`), Parameter.value(`backend`))) let perform = methodPerformValue(.m_login__externalToken_externalTokenbackend_backend(Parameter.value(`externalToken`), Parameter.value(`backend`))) as? (String, String) -> Void perform?(`externalToken`, `backend`) @@ -110,6 +93,22 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { return __value } + open func login(ssoToken: String) throws -> User { + addInvocation(.m_login__ssoToken_ssoToken(Parameter.value(`ssoToken`))) + let perform = methodPerformValue(.m_login__ssoToken_ssoToken(Parameter.value(`ssoToken`))) as? (String) -> Void + perform?(`ssoToken`) + var __value: User + do { + __value = try methodReturnValue(.m_login__ssoToken_ssoToken(Parameter.value(`ssoToken`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for login(ssoToken: String). Use given") + Failure("Stub return value not specified for login(ssoToken: String). Use given") + } catch { + throw error + } + return __value + } + open func resetPassword(email: String) throws -> ResetPassword { addInvocation(.m_resetPassword__email_email(Parameter.value(`email`))) let perform = methodPerformValue(.m_resetPassword__email_email(Parameter.value(`email`))) as? (String) -> Void @@ -190,8 +189,8 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { fileprivate enum MethodType { case m_login__username_usernamepassword_password(Parameter, Parameter) - case m_login__SSO__username_password(Parameter) case m_login__externalToken_externalTokenbackend_backend(Parameter, Parameter) + case m_login__ssoToken_ssoToken(Parameter) case m_resetPassword__email_email(Parameter) case m_getCookies__force_force(Parameter) case m_getRegistrationFields @@ -212,6 +211,11 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBackend, rhs: rhsBackend, with: matcher), lhsBackend, rhsBackend, "backend")) return Matcher.ComparisonResult(results) + case (.m_login__ssoToken_ssoToken(let lhsSsotoken), .m_login__ssoToken_ssoToken(let rhsSsotoken)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSsotoken, rhs: rhsSsotoken, with: matcher), lhsSsotoken, rhsSsotoken, "ssoToken")) + return Matcher.ComparisonResult(results) + case (.m_resetPassword__email_email(let lhsEmail), .m_resetPassword__email_email(let rhsEmail)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEmail, rhs: rhsEmail, with: matcher), lhsEmail, rhsEmail, "email")) @@ -241,8 +245,8 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func intValue() -> Int { switch self { case let .m_login__username_usernamepassword_password(p0, p1): return p0.intValue + p1.intValue - case let .m_login__SSO__username_password(p0): return p0.intValue case let .m_login__externalToken_externalTokenbackend_backend(p0, p1): return p0.intValue + p1.intValue + case let .m_login__ssoToken_ssoToken(p0): return p0.intValue case let .m_resetPassword__email_email(p0): return p0.intValue case let .m_getCookies__force_force(p0): return p0.intValue case .m_getRegistrationFields: return 0 @@ -253,8 +257,8 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func assertionName() -> String { switch self { case .m_login__username_usernamepassword_password: return ".login(username:password:)" - case .m_login__SSO__username_password: return ".loginSSO(username:password:)" case .m_login__externalToken_externalTokenbackend_backend: return ".login(externalToken:backend:)" + case .m_login__ssoToken_ssoToken: return ".login(ssoToken:)" case .m_resetPassword__email_email: return ".resetPassword(email:)" case .m_getCookies__force_force: return ".getCookies(force:)" case .m_getRegistrationFields: return ".getRegistrationFields()" @@ -281,6 +285,9 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func login(externalToken: Parameter, backend: Parameter, willReturn: User...) -> MethodStub { return Given(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func login(ssoToken: Parameter, willReturn: User...) -> MethodStub { + return Given(method: .m_login__ssoToken_ssoToken(`ssoToken`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func resetPassword(email: Parameter, willReturn: ResetPassword...) -> MethodStub { return Given(method: .m_resetPassword__email_email(`email`), products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -317,6 +324,16 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { willProduce(stubber) return given } + public static func login(ssoToken: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_login__ssoToken_ssoToken(`ssoToken`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func login(ssoToken: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_login__ssoToken_ssoToken(`ssoToken`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (User).self) + willProduce(stubber) + return given + } public static func resetPassword(email: Parameter, willThrow: Error...) -> MethodStub { return Given(method: .m_resetPassword__email_email(`email`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -376,6 +393,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func login(username: Parameter, password: Parameter) -> Verify { return Verify(method: .m_login__username_usernamepassword_password(`username`, `password`))} @discardableResult public static func login(externalToken: Parameter, backend: Parameter) -> Verify { return Verify(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`))} + public static func login(ssoToken: Parameter) -> Verify { return Verify(method: .m_login__ssoToken_ssoToken(`ssoToken`))} public static func resetPassword(email: Parameter) -> Verify { return Verify(method: .m_resetPassword__email_email(`email`))} public static func getCookies(force: Parameter) -> Verify { return Verify(method: .m_getCookies__force_force(`force`))} public static func getRegistrationFields() -> Verify { return Verify(method: .m_getRegistrationFields)} @@ -395,6 +413,9 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func login(externalToken: Parameter, backend: Parameter, perform: @escaping (String, String) -> Void) -> Perform { return Perform(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`), performs: perform) } + public static func login(ssoToken: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_login__ssoToken_ssoToken(`ssoToken`), performs: perform) + } public static func resetPassword(email: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_resetPassword__email_email(`email`), performs: perform) } @@ -597,16 +618,16 @@ open class BaseRouterMock: BaseRouter, Mock { open func showWebBrowser(title: String, url: URL) { addInvocation(.m_showWebBrowser__title_titleurl_url(Parameter.value(`title`), Parameter.value(`url`))) - let perform = methodPerformValue(.m_showWebBrowser__title_titleurl_url(Parameter.value(`title`), Parameter.value(`url`))) as? (String, URL) -> Void - perform?(`title`, `url`) + let perform = methodPerformValue(.m_showWebBrowser__title_titleurl_url(Parameter.value(`title`), Parameter.value(`url`))) as? (String, URL) -> Void + perform?(`title`, `url`) } - + open func showSSOWebBrowser(title: String) { - addInvocation(.m_showWebBrowser__SSO(Parameter.value(`title`))) - let perform = methodPerformValue(.m_showWebBrowser__SSO(Parameter.value(`title`))) as? (String) -> Void - perform?(`title`) + addInvocation(.m_showSSOWebBrowser__title_title(Parameter.value(`title`))) + let perform = methodPerformValue(.m_showSSOWebBrowser__title_title(Parameter.value(`title`))) as? (String) -> Void + perform?(`title`) } - + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -645,7 +666,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showForgotPasswordScreen case m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(Parameter, Parameter) case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) - case m_showWebBrowser__SSO(Parameter) + case m_showSSOWebBrowser__title_title(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_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) @@ -706,6 +727,11 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUrl, rhs: rhsUrl, with: matcher), lhsUrl, rhsUrl, "url")) return Matcher.ComparisonResult(results) + case (.m_showSSOWebBrowser__title_title(let lhsTitle), .m_showSSOWebBrowser__title_title(let rhsTitle)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTitle, rhs: rhsTitle, with: matcher), lhsTitle, rhsTitle, "title")) + return Matcher.ComparisonResult(results) + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let lhsAlerttitle, let lhsAlertmessage, let lhsPositiveaction, let lhsOnclosetapped, let lhsOktapped, let lhsType), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let rhsAlerttitle, let rhsAlertmessage, let rhsPositiveaction, let rhsOnclosetapped, let rhsOktapped, let rhsType)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) @@ -759,7 +785,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return 0 case let .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(p0, p1): return p0.intValue + p1.intValue case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue - case let .m_showWebBrowser__SSO(p0): return p0.intValue + case let .m_showSSOWebBrowser__title_title(p0): return p0.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_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue @@ -780,7 +806,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen: return ".showDiscoveryScreen(searchQuery:sourceScreen:)" case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" - case .m_showWebBrowser__SSO: return ".showSSOWebBrowser(title:)" + case .m_showSSOWebBrowser__title_title: return ".showSSOWebBrowser(title:)" 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_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" @@ -815,6 +841,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} public static func showDiscoveryScreen(searchQuery: Parameter, sourceScreen: Parameter) -> Verify { return Verify(method: .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(`searchQuery`, `sourceScreen`))} public static func showWebBrowser(title: Parameter, url: Parameter) -> Verify { return Verify(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`))} + public static func showSSOWebBrowser(title: Parameter) -> Verify { return Verify(method: .m_showSSOWebBrowser__title_title(`title`))} 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, completion: Parameter<(() -> Void)?>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`))} @@ -861,6 +888,9 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showWebBrowser(title: Parameter, url: Parameter, perform: @escaping (String, URL) -> Void) -> Perform { return Perform(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`), performs: perform) } + public static func showSSOWebBrowser(title: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_showSSOWebBrowser__title_title(`title`), performs: perform) + } public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } @@ -2100,6 +2130,406 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { } } +// MARK: - CoreStorage + +open class CoreStorageMock: CoreStorage, 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 accessToken: String? { + get { invocations.append(.p_accessToken_get); return __p_accessToken ?? optionalGivenGetterValue(.p_accessToken_get, "CoreStorageMock - stub value for accessToken was not defined") } + set { invocations.append(.p_accessToken_set(.value(newValue))); __p_accessToken = newValue } + } + private var __p_accessToken: (String)? + + public var refreshToken: String? { + get { invocations.append(.p_refreshToken_get); return __p_refreshToken ?? optionalGivenGetterValue(.p_refreshToken_get, "CoreStorageMock - stub value for refreshToken was not defined") } + set { invocations.append(.p_refreshToken_set(.value(newValue))); __p_refreshToken = newValue } + } + private var __p_refreshToken: (String)? + + public var pushToken: String? { + get { invocations.append(.p_pushToken_get); return __p_pushToken ?? optionalGivenGetterValue(.p_pushToken_get, "CoreStorageMock - stub value for pushToken was not defined") } + set { invocations.append(.p_pushToken_set(.value(newValue))); __p_pushToken = newValue } + } + private var __p_pushToken: (String)? + + public var appleSignFullName: String? { + get { invocations.append(.p_appleSignFullName_get); return __p_appleSignFullName ?? optionalGivenGetterValue(.p_appleSignFullName_get, "CoreStorageMock - stub value for appleSignFullName was not defined") } + set { invocations.append(.p_appleSignFullName_set(.value(newValue))); __p_appleSignFullName = newValue } + } + private var __p_appleSignFullName: (String)? + + public var appleSignEmail: String? { + get { invocations.append(.p_appleSignEmail_get); return __p_appleSignEmail ?? optionalGivenGetterValue(.p_appleSignEmail_get, "CoreStorageMock - stub value for appleSignEmail was not defined") } + set { invocations.append(.p_appleSignEmail_set(.value(newValue))); __p_appleSignEmail = newValue } + } + private var __p_appleSignEmail: (String)? + + public var cookiesDate: Date? { + get { invocations.append(.p_cookiesDate_get); return __p_cookiesDate ?? optionalGivenGetterValue(.p_cookiesDate_get, "CoreStorageMock - stub value for cookiesDate was not defined") } + set { invocations.append(.p_cookiesDate_set(.value(newValue))); __p_cookiesDate = newValue } + } + private var __p_cookiesDate: (Date)? + + public var reviewLastShownVersion: String? { + get { invocations.append(.p_reviewLastShownVersion_get); return __p_reviewLastShownVersion ?? optionalGivenGetterValue(.p_reviewLastShownVersion_get, "CoreStorageMock - stub value for reviewLastShownVersion was not defined") } + set { invocations.append(.p_reviewLastShownVersion_set(.value(newValue))); __p_reviewLastShownVersion = newValue } + } + private var __p_reviewLastShownVersion: (String)? + + public var lastReviewDate: Date? { + get { invocations.append(.p_lastReviewDate_get); return __p_lastReviewDate ?? optionalGivenGetterValue(.p_lastReviewDate_get, "CoreStorageMock - stub value for lastReviewDate was not defined") } + set { invocations.append(.p_lastReviewDate_set(.value(newValue))); __p_lastReviewDate = newValue } + } + private var __p_lastReviewDate: (Date)? + + public var user: DataLayer.User? { + get { invocations.append(.p_user_get); return __p_user ?? optionalGivenGetterValue(.p_user_get, "CoreStorageMock - stub value for user was not defined") } + set { invocations.append(.p_user_set(.value(newValue))); __p_user = newValue } + } + private var __p_user: (DataLayer.User)? + + public var userSettings: UserSettings? { + get { invocations.append(.p_userSettings_get); return __p_userSettings ?? optionalGivenGetterValue(.p_userSettings_get, "CoreStorageMock - stub value for userSettings was not defined") } + set { invocations.append(.p_userSettings_set(.value(newValue))); __p_userSettings = newValue } + } + private var __p_userSettings: (UserSettings)? + + public var resetAppSupportDirectoryUserData: Bool? { + get { invocations.append(.p_resetAppSupportDirectoryUserData_get); return __p_resetAppSupportDirectoryUserData ?? optionalGivenGetterValue(.p_resetAppSupportDirectoryUserData_get, "CoreStorageMock - stub value for resetAppSupportDirectoryUserData was not defined") } + set { invocations.append(.p_resetAppSupportDirectoryUserData_set(.value(newValue))); __p_resetAppSupportDirectoryUserData = newValue } + } + private var __p_resetAppSupportDirectoryUserData: (Bool)? + + public var useRelativeDates: Bool { + get { invocations.append(.p_useRelativeDates_get); return __p_useRelativeDates ?? givenGetterValue(.p_useRelativeDates_get, "CoreStorageMock - stub value for useRelativeDates was not defined") } + set { invocations.append(.p_useRelativeDates_set(.value(newValue))); __p_useRelativeDates = newValue } + } + private var __p_useRelativeDates: (Bool)? + + + + + + open func clear() { + addInvocation(.m_clear) + let perform = methodPerformValue(.m_clear) as? () -> Void + perform?() + } + + + fileprivate enum MethodType { + case m_clear + case p_accessToken_get + case p_accessToken_set(Parameter) + case p_refreshToken_get + case p_refreshToken_set(Parameter) + case p_pushToken_get + case p_pushToken_set(Parameter) + case p_appleSignFullName_get + case p_appleSignFullName_set(Parameter) + case p_appleSignEmail_get + case p_appleSignEmail_set(Parameter) + case p_cookiesDate_get + case p_cookiesDate_set(Parameter) + case p_reviewLastShownVersion_get + case p_reviewLastShownVersion_set(Parameter) + case p_lastReviewDate_get + case p_lastReviewDate_set(Parameter) + case p_user_get + case p_user_set(Parameter) + case p_userSettings_get + case p_userSettings_set(Parameter) + case p_resetAppSupportDirectoryUserData_get + case p_resetAppSupportDirectoryUserData_set(Parameter) + case p_useRelativeDates_get + case p_useRelativeDates_set(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_clear, .m_clear): return .match + case (.p_accessToken_get,.p_accessToken_get): return Matcher.ComparisonResult.match + case (.p_accessToken_set(let left),.p_accessToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_refreshToken_get,.p_refreshToken_get): return Matcher.ComparisonResult.match + case (.p_refreshToken_set(let left),.p_refreshToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_pushToken_get,.p_pushToken_get): return Matcher.ComparisonResult.match + case (.p_pushToken_set(let left),.p_pushToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_appleSignFullName_get,.p_appleSignFullName_get): return Matcher.ComparisonResult.match + case (.p_appleSignFullName_set(let left),.p_appleSignFullName_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_appleSignEmail_get,.p_appleSignEmail_get): return Matcher.ComparisonResult.match + case (.p_appleSignEmail_set(let left),.p_appleSignEmail_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_cookiesDate_get,.p_cookiesDate_get): return Matcher.ComparisonResult.match + case (.p_cookiesDate_set(let left),.p_cookiesDate_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_reviewLastShownVersion_get,.p_reviewLastShownVersion_get): return Matcher.ComparisonResult.match + case (.p_reviewLastShownVersion_set(let left),.p_reviewLastShownVersion_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_lastReviewDate_get,.p_lastReviewDate_get): return Matcher.ComparisonResult.match + case (.p_lastReviewDate_set(let left),.p_lastReviewDate_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_user_get,.p_user_get): return Matcher.ComparisonResult.match + case (.p_user_set(let left),.p_user_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_userSettings_get,.p_userSettings_get): return Matcher.ComparisonResult.match + case (.p_userSettings_set(let left),.p_userSettings_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_resetAppSupportDirectoryUserData_get,.p_resetAppSupportDirectoryUserData_get): return Matcher.ComparisonResult.match + case (.p_resetAppSupportDirectoryUserData_set(let left),.p_resetAppSupportDirectoryUserData_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_useRelativeDates_get,.p_useRelativeDates_get): return Matcher.ComparisonResult.match + case (.p_useRelativeDates_set(let left),.p_useRelativeDates_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 .m_clear: return 0 + case .p_accessToken_get: return 0 + case .p_accessToken_set(let newValue): return newValue.intValue + case .p_refreshToken_get: return 0 + case .p_refreshToken_set(let newValue): return newValue.intValue + case .p_pushToken_get: return 0 + case .p_pushToken_set(let newValue): return newValue.intValue + case .p_appleSignFullName_get: return 0 + case .p_appleSignFullName_set(let newValue): return newValue.intValue + case .p_appleSignEmail_get: return 0 + case .p_appleSignEmail_set(let newValue): return newValue.intValue + case .p_cookiesDate_get: return 0 + case .p_cookiesDate_set(let newValue): return newValue.intValue + case .p_reviewLastShownVersion_get: return 0 + case .p_reviewLastShownVersion_set(let newValue): return newValue.intValue + case .p_lastReviewDate_get: return 0 + case .p_lastReviewDate_set(let newValue): return newValue.intValue + case .p_user_get: return 0 + case .p_user_set(let newValue): return newValue.intValue + case .p_userSettings_get: return 0 + case .p_userSettings_set(let newValue): return newValue.intValue + case .p_resetAppSupportDirectoryUserData_get: return 0 + case .p_resetAppSupportDirectoryUserData_set(let newValue): return newValue.intValue + case .p_useRelativeDates_get: return 0 + case .p_useRelativeDates_set(let newValue): return newValue.intValue + } + } + func assertionName() -> String { + switch self { + case .m_clear: return ".clear()" + case .p_accessToken_get: return "[get] .accessToken" + case .p_accessToken_set: return "[set] .accessToken" + case .p_refreshToken_get: return "[get] .refreshToken" + case .p_refreshToken_set: return "[set] .refreshToken" + case .p_pushToken_get: return "[get] .pushToken" + case .p_pushToken_set: return "[set] .pushToken" + case .p_appleSignFullName_get: return "[get] .appleSignFullName" + case .p_appleSignFullName_set: return "[set] .appleSignFullName" + case .p_appleSignEmail_get: return "[get] .appleSignEmail" + case .p_appleSignEmail_set: return "[set] .appleSignEmail" + case .p_cookiesDate_get: return "[get] .cookiesDate" + case .p_cookiesDate_set: return "[set] .cookiesDate" + case .p_reviewLastShownVersion_get: return "[get] .reviewLastShownVersion" + case .p_reviewLastShownVersion_set: return "[set] .reviewLastShownVersion" + case .p_lastReviewDate_get: return "[get] .lastReviewDate" + case .p_lastReviewDate_set: return "[set] .lastReviewDate" + case .p_user_get: return "[get] .user" + case .p_user_set: return "[set] .user" + case .p_userSettings_get: return "[get] .userSettings" + case .p_userSettings_set: return "[set] .userSettings" + case .p_resetAppSupportDirectoryUserData_get: return "[get] .resetAppSupportDirectoryUserData" + case .p_resetAppSupportDirectoryUserData_set: return "[set] .resetAppSupportDirectoryUserData" + case .p_useRelativeDates_get: return "[get] .useRelativeDates" + case .p_useRelativeDates_set: return "[set] .useRelativeDates" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func accessToken(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_accessToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func refreshToken(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_refreshToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func pushToken(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_pushToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appleSignFullName(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_appleSignFullName_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appleSignEmail(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_appleSignEmail_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func cookiesDate(getter defaultValue: Date?...) -> PropertyStub { + return Given(method: .p_cookiesDate_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func reviewLastShownVersion(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_reviewLastShownVersion_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func lastReviewDate(getter defaultValue: Date?...) -> PropertyStub { + return Given(method: .p_lastReviewDate_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func user(getter defaultValue: DataLayer.User?...) -> PropertyStub { + return Given(method: .p_user_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func userSettings(getter defaultValue: UserSettings?...) -> PropertyStub { + return Given(method: .p_userSettings_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func resetAppSupportDirectoryUserData(getter defaultValue: Bool?...) -> PropertyStub { + return Given(method: .p_resetAppSupportDirectoryUserData_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func useRelativeDates(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_useRelativeDates_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func clear() -> Verify { return Verify(method: .m_clear)} + public static var accessToken: Verify { return Verify(method: .p_accessToken_get) } + public static func accessToken(set newValue: Parameter) -> Verify { return Verify(method: .p_accessToken_set(newValue)) } + public static var refreshToken: Verify { return Verify(method: .p_refreshToken_get) } + public static func refreshToken(set newValue: Parameter) -> Verify { return Verify(method: .p_refreshToken_set(newValue)) } + public static var pushToken: Verify { return Verify(method: .p_pushToken_get) } + public static func pushToken(set newValue: Parameter) -> Verify { return Verify(method: .p_pushToken_set(newValue)) } + public static var appleSignFullName: Verify { return Verify(method: .p_appleSignFullName_get) } + public static func appleSignFullName(set newValue: Parameter) -> Verify { return Verify(method: .p_appleSignFullName_set(newValue)) } + public static var appleSignEmail: Verify { return Verify(method: .p_appleSignEmail_get) } + public static func appleSignEmail(set newValue: Parameter) -> Verify { return Verify(method: .p_appleSignEmail_set(newValue)) } + public static var cookiesDate: Verify { return Verify(method: .p_cookiesDate_get) } + public static func cookiesDate(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesDate_set(newValue)) } + public static var reviewLastShownVersion: Verify { return Verify(method: .p_reviewLastShownVersion_get) } + public static func reviewLastShownVersion(set newValue: Parameter) -> Verify { return Verify(method: .p_reviewLastShownVersion_set(newValue)) } + public static var lastReviewDate: Verify { return Verify(method: .p_lastReviewDate_get) } + public static func lastReviewDate(set newValue: Parameter) -> Verify { return Verify(method: .p_lastReviewDate_set(newValue)) } + public static var user: Verify { return Verify(method: .p_user_get) } + public static func user(set newValue: Parameter) -> Verify { return Verify(method: .p_user_set(newValue)) } + public static var userSettings: Verify { return Verify(method: .p_userSettings_get) } + public static func userSettings(set newValue: Parameter) -> Verify { return Verify(method: .p_userSettings_set(newValue)) } + public static var resetAppSupportDirectoryUserData: Verify { return Verify(method: .p_resetAppSupportDirectoryUserData_get) } + public static func resetAppSupportDirectoryUserData(set newValue: Parameter) -> Verify { return Verify(method: .p_resetAppSupportDirectoryUserData_set(newValue)) } + public static var useRelativeDates: Verify { return Verify(method: .p_useRelativeDates_get) } + public static func useRelativeDates(set newValue: Parameter) -> Verify { return Verify(method: .p_useRelativeDates_set(newValue)) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func clear(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_clear, 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 { diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index a3ac7af27..a63173694 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -93,23 +93,22 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { return __value } - @discardableResult - open func login(ssoToken: String) async throws -> Core.User { - addInvocation(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) - let perform = methodPerformValue(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) as? (String) -> Void - perform?(`ssoToken`) - var __value: User - do { - __value = try methodReturnValue(.m_login__SSO__username_password(Parameter.value(ssoToken))).casted() - } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for login(username: String, password: String). Use given") - Failure("Stub return value not specified for login(username: String, password: String). Use given") - } catch { - throw error - } - return __value + open func login(ssoToken: String) throws -> User { + addInvocation(.m_login__ssoToken_ssoToken(Parameter.value(`ssoToken`))) + let perform = methodPerformValue(.m_login__ssoToken_ssoToken(Parameter.value(`ssoToken`))) as? (String) -> Void + perform?(`ssoToken`) + var __value: User + do { + __value = try methodReturnValue(.m_login__ssoToken_ssoToken(Parameter.value(`ssoToken`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for login(ssoToken: String). Use given") + Failure("Stub return value not specified for login(ssoToken: String). Use given") + } catch { + throw error + } + return __value } - + open func resetPassword(email: String) throws -> ResetPassword { addInvocation(.m_resetPassword__email_email(Parameter.value(`email`))) let perform = methodPerformValue(.m_resetPassword__email_email(Parameter.value(`email`))) as? (String) -> Void @@ -190,8 +189,8 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { fileprivate enum MethodType { case m_login__username_usernamepassword_password(Parameter, Parameter) - case m_login__SSO__username_password(Parameter) case m_login__externalToken_externalTokenbackend_backend(Parameter, Parameter) + case m_login__ssoToken_ssoToken(Parameter) case m_resetPassword__email_email(Parameter) case m_getCookies__force_force(Parameter) case m_getRegistrationFields @@ -212,6 +211,11 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBackend, rhs: rhsBackend, with: matcher), lhsBackend, rhsBackend, "backend")) return Matcher.ComparisonResult(results) + case (.m_login__ssoToken_ssoToken(let lhsSsotoken), .m_login__ssoToken_ssoToken(let rhsSsotoken)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSsotoken, rhs: rhsSsotoken, with: matcher), lhsSsotoken, rhsSsotoken, "ssoToken")) + return Matcher.ComparisonResult(results) + case (.m_resetPassword__email_email(let lhsEmail), .m_resetPassword__email_email(let rhsEmail)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEmail, rhs: rhsEmail, with: matcher), lhsEmail, rhsEmail, "email")) @@ -241,8 +245,8 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func intValue() -> Int { switch self { case let .m_login__username_usernamepassword_password(p0, p1): return p0.intValue + p1.intValue - case let .m_login__SSO__username_password(p0): return p0.intValue case let .m_login__externalToken_externalTokenbackend_backend(p0, p1): return p0.intValue + p1.intValue + case let .m_login__ssoToken_ssoToken(p0): return p0.intValue case let .m_resetPassword__email_email(p0): return p0.intValue case let .m_getCookies__force_force(p0): return p0.intValue case .m_getRegistrationFields: return 0 @@ -253,8 +257,8 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func assertionName() -> String { switch self { case .m_login__username_usernamepassword_password: return ".login(username:password:)" - case .m_login__SSO__username_password: return ".loginSSO(username:password:)" case .m_login__externalToken_externalTokenbackend_backend: return ".login(externalToken:backend:)" + case .m_login__ssoToken_ssoToken: return ".login(ssoToken:)" case .m_resetPassword__email_email: return ".resetPassword(email:)" case .m_getCookies__force_force: return ".getCookies(force:)" case .m_getRegistrationFields: return ".getRegistrationFields()" @@ -281,6 +285,9 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func login(externalToken: Parameter, backend: Parameter, willReturn: User...) -> MethodStub { return Given(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func login(ssoToken: Parameter, willReturn: User...) -> MethodStub { + return Given(method: .m_login__ssoToken_ssoToken(`ssoToken`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func resetPassword(email: Parameter, willReturn: ResetPassword...) -> MethodStub { return Given(method: .m_resetPassword__email_email(`email`), products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -317,6 +324,16 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { willProduce(stubber) return given } + public static func login(ssoToken: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_login__ssoToken_ssoToken(`ssoToken`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func login(ssoToken: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_login__ssoToken_ssoToken(`ssoToken`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (User).self) + willProduce(stubber) + return given + } public static func resetPassword(email: Parameter, willThrow: Error...) -> MethodStub { return Given(method: .m_resetPassword__email_email(`email`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -376,6 +393,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func login(username: Parameter, password: Parameter) -> Verify { return Verify(method: .m_login__username_usernamepassword_password(`username`, `password`))} @discardableResult public static func login(externalToken: Parameter, backend: Parameter) -> Verify { return Verify(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`))} + public static func login(ssoToken: Parameter) -> Verify { return Verify(method: .m_login__ssoToken_ssoToken(`ssoToken`))} public static func resetPassword(email: Parameter) -> Verify { return Verify(method: .m_resetPassword__email_email(`email`))} public static func getCookies(force: Parameter) -> Verify { return Verify(method: .m_getCookies__force_force(`force`))} public static func getRegistrationFields() -> Verify { return Verify(method: .m_getRegistrationFields)} @@ -395,6 +413,9 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func login(externalToken: Parameter, backend: Parameter, perform: @escaping (String, String) -> Void) -> Perform { return Perform(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`), performs: perform) } + public static func login(ssoToken: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_login__ssoToken_ssoToken(`ssoToken`), performs: perform) + } public static func resetPassword(email: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_resetPassword__email_email(`email`), performs: perform) } @@ -602,11 +623,11 @@ open class BaseRouterMock: BaseRouter, Mock { } open func showSSOWebBrowser(title: String) { - addInvocation(.m_showWebBrowser__SSO(Parameter.value(`title`))) - let perform = methodPerformValue(.m_showWebBrowser__SSO(Parameter.value(`title`))) as? (String) -> Void - perform?(`title`) + addInvocation(.m_showSSOWebBrowser__title_title(Parameter.value(`title`))) + let perform = methodPerformValue(.m_showSSOWebBrowser__title_title(Parameter.value(`title`))) as? (String) -> Void + perform?(`title`) } - + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -645,7 +666,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showForgotPasswordScreen case m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(Parameter, Parameter) case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) - case m_showWebBrowser__SSO(Parameter) + case m_showSSOWebBrowser__title_title(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_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) @@ -706,6 +727,11 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUrl, rhs: rhsUrl, with: matcher), lhsUrl, rhsUrl, "url")) return Matcher.ComparisonResult(results) + case (.m_showSSOWebBrowser__title_title(let lhsTitle), .m_showSSOWebBrowser__title_title(let rhsTitle)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTitle, rhs: rhsTitle, with: matcher), lhsTitle, rhsTitle, "title")) + return Matcher.ComparisonResult(results) + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let lhsAlerttitle, let lhsAlertmessage, let lhsPositiveaction, let lhsOnclosetapped, let lhsOktapped, let lhsType), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let rhsAlerttitle, let rhsAlertmessage, let rhsPositiveaction, let rhsOnclosetapped, let rhsOktapped, let rhsType)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) @@ -759,7 +785,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return 0 case let .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(p0, p1): return p0.intValue + p1.intValue case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue - case let .m_showWebBrowser__SSO(p0): return p0.intValue + case let .m_showSSOWebBrowser__title_title(p0): return p0.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_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue @@ -780,7 +806,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen: return ".showDiscoveryScreen(searchQuery:sourceScreen:)" case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" - case .m_showWebBrowser__SSO: return ".showSSOWebBrowser(title:)" + case .m_showSSOWebBrowser__title_title: return ".showSSOWebBrowser(title:)" 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_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" @@ -815,6 +841,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} public static func showDiscoveryScreen(searchQuery: Parameter, sourceScreen: Parameter) -> Verify { return Verify(method: .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(`searchQuery`, `sourceScreen`))} public static func showWebBrowser(title: Parameter, url: Parameter) -> Verify { return Verify(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`))} + public static func showSSOWebBrowser(title: Parameter) -> Verify { return Verify(method: .m_showSSOWebBrowser__title_title(`title`))} 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, completion: Parameter<(() -> Void)?>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`))} @@ -861,6 +888,9 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showWebBrowser(title: Parameter, url: Parameter, perform: @escaping (String, URL) -> Void) -> Perform { return Perform(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`), performs: perform) } + public static func showSSOWebBrowser(title: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_showSSOWebBrowser__title_title(`title`), performs: perform) + } public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } @@ -2100,6 +2130,406 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { } } +// MARK: - CoreStorage + +open class CoreStorageMock: CoreStorage, 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 accessToken: String? { + get { invocations.append(.p_accessToken_get); return __p_accessToken ?? optionalGivenGetterValue(.p_accessToken_get, "CoreStorageMock - stub value for accessToken was not defined") } + set { invocations.append(.p_accessToken_set(.value(newValue))); __p_accessToken = newValue } + } + private var __p_accessToken: (String)? + + public var refreshToken: String? { + get { invocations.append(.p_refreshToken_get); return __p_refreshToken ?? optionalGivenGetterValue(.p_refreshToken_get, "CoreStorageMock - stub value for refreshToken was not defined") } + set { invocations.append(.p_refreshToken_set(.value(newValue))); __p_refreshToken = newValue } + } + private var __p_refreshToken: (String)? + + public var pushToken: String? { + get { invocations.append(.p_pushToken_get); return __p_pushToken ?? optionalGivenGetterValue(.p_pushToken_get, "CoreStorageMock - stub value for pushToken was not defined") } + set { invocations.append(.p_pushToken_set(.value(newValue))); __p_pushToken = newValue } + } + private var __p_pushToken: (String)? + + public var appleSignFullName: String? { + get { invocations.append(.p_appleSignFullName_get); return __p_appleSignFullName ?? optionalGivenGetterValue(.p_appleSignFullName_get, "CoreStorageMock - stub value for appleSignFullName was not defined") } + set { invocations.append(.p_appleSignFullName_set(.value(newValue))); __p_appleSignFullName = newValue } + } + private var __p_appleSignFullName: (String)? + + public var appleSignEmail: String? { + get { invocations.append(.p_appleSignEmail_get); return __p_appleSignEmail ?? optionalGivenGetterValue(.p_appleSignEmail_get, "CoreStorageMock - stub value for appleSignEmail was not defined") } + set { invocations.append(.p_appleSignEmail_set(.value(newValue))); __p_appleSignEmail = newValue } + } + private var __p_appleSignEmail: (String)? + + public var cookiesDate: Date? { + get { invocations.append(.p_cookiesDate_get); return __p_cookiesDate ?? optionalGivenGetterValue(.p_cookiesDate_get, "CoreStorageMock - stub value for cookiesDate was not defined") } + set { invocations.append(.p_cookiesDate_set(.value(newValue))); __p_cookiesDate = newValue } + } + private var __p_cookiesDate: (Date)? + + public var reviewLastShownVersion: String? { + get { invocations.append(.p_reviewLastShownVersion_get); return __p_reviewLastShownVersion ?? optionalGivenGetterValue(.p_reviewLastShownVersion_get, "CoreStorageMock - stub value for reviewLastShownVersion was not defined") } + set { invocations.append(.p_reviewLastShownVersion_set(.value(newValue))); __p_reviewLastShownVersion = newValue } + } + private var __p_reviewLastShownVersion: (String)? + + public var lastReviewDate: Date? { + get { invocations.append(.p_lastReviewDate_get); return __p_lastReviewDate ?? optionalGivenGetterValue(.p_lastReviewDate_get, "CoreStorageMock - stub value for lastReviewDate was not defined") } + set { invocations.append(.p_lastReviewDate_set(.value(newValue))); __p_lastReviewDate = newValue } + } + private var __p_lastReviewDate: (Date)? + + public var user: DataLayer.User? { + get { invocations.append(.p_user_get); return __p_user ?? optionalGivenGetterValue(.p_user_get, "CoreStorageMock - stub value for user was not defined") } + set { invocations.append(.p_user_set(.value(newValue))); __p_user = newValue } + } + private var __p_user: (DataLayer.User)? + + public var userSettings: UserSettings? { + get { invocations.append(.p_userSettings_get); return __p_userSettings ?? optionalGivenGetterValue(.p_userSettings_get, "CoreStorageMock - stub value for userSettings was not defined") } + set { invocations.append(.p_userSettings_set(.value(newValue))); __p_userSettings = newValue } + } + private var __p_userSettings: (UserSettings)? + + public var resetAppSupportDirectoryUserData: Bool? { + get { invocations.append(.p_resetAppSupportDirectoryUserData_get); return __p_resetAppSupportDirectoryUserData ?? optionalGivenGetterValue(.p_resetAppSupportDirectoryUserData_get, "CoreStorageMock - stub value for resetAppSupportDirectoryUserData was not defined") } + set { invocations.append(.p_resetAppSupportDirectoryUserData_set(.value(newValue))); __p_resetAppSupportDirectoryUserData = newValue } + } + private var __p_resetAppSupportDirectoryUserData: (Bool)? + + public var useRelativeDates: Bool { + get { invocations.append(.p_useRelativeDates_get); return __p_useRelativeDates ?? givenGetterValue(.p_useRelativeDates_get, "CoreStorageMock - stub value for useRelativeDates was not defined") } + set { invocations.append(.p_useRelativeDates_set(.value(newValue))); __p_useRelativeDates = newValue } + } + private var __p_useRelativeDates: (Bool)? + + + + + + open func clear() { + addInvocation(.m_clear) + let perform = methodPerformValue(.m_clear) as? () -> Void + perform?() + } + + + fileprivate enum MethodType { + case m_clear + case p_accessToken_get + case p_accessToken_set(Parameter) + case p_refreshToken_get + case p_refreshToken_set(Parameter) + case p_pushToken_get + case p_pushToken_set(Parameter) + case p_appleSignFullName_get + case p_appleSignFullName_set(Parameter) + case p_appleSignEmail_get + case p_appleSignEmail_set(Parameter) + case p_cookiesDate_get + case p_cookiesDate_set(Parameter) + case p_reviewLastShownVersion_get + case p_reviewLastShownVersion_set(Parameter) + case p_lastReviewDate_get + case p_lastReviewDate_set(Parameter) + case p_user_get + case p_user_set(Parameter) + case p_userSettings_get + case p_userSettings_set(Parameter) + case p_resetAppSupportDirectoryUserData_get + case p_resetAppSupportDirectoryUserData_set(Parameter) + case p_useRelativeDates_get + case p_useRelativeDates_set(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_clear, .m_clear): return .match + case (.p_accessToken_get,.p_accessToken_get): return Matcher.ComparisonResult.match + case (.p_accessToken_set(let left),.p_accessToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_refreshToken_get,.p_refreshToken_get): return Matcher.ComparisonResult.match + case (.p_refreshToken_set(let left),.p_refreshToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_pushToken_get,.p_pushToken_get): return Matcher.ComparisonResult.match + case (.p_pushToken_set(let left),.p_pushToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_appleSignFullName_get,.p_appleSignFullName_get): return Matcher.ComparisonResult.match + case (.p_appleSignFullName_set(let left),.p_appleSignFullName_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_appleSignEmail_get,.p_appleSignEmail_get): return Matcher.ComparisonResult.match + case (.p_appleSignEmail_set(let left),.p_appleSignEmail_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_cookiesDate_get,.p_cookiesDate_get): return Matcher.ComparisonResult.match + case (.p_cookiesDate_set(let left),.p_cookiesDate_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_reviewLastShownVersion_get,.p_reviewLastShownVersion_get): return Matcher.ComparisonResult.match + case (.p_reviewLastShownVersion_set(let left),.p_reviewLastShownVersion_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_lastReviewDate_get,.p_lastReviewDate_get): return Matcher.ComparisonResult.match + case (.p_lastReviewDate_set(let left),.p_lastReviewDate_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_user_get,.p_user_get): return Matcher.ComparisonResult.match + case (.p_user_set(let left),.p_user_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_userSettings_get,.p_userSettings_get): return Matcher.ComparisonResult.match + case (.p_userSettings_set(let left),.p_userSettings_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_resetAppSupportDirectoryUserData_get,.p_resetAppSupportDirectoryUserData_get): return Matcher.ComparisonResult.match + case (.p_resetAppSupportDirectoryUserData_set(let left),.p_resetAppSupportDirectoryUserData_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_useRelativeDates_get,.p_useRelativeDates_get): return Matcher.ComparisonResult.match + case (.p_useRelativeDates_set(let left),.p_useRelativeDates_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 .m_clear: return 0 + case .p_accessToken_get: return 0 + case .p_accessToken_set(let newValue): return newValue.intValue + case .p_refreshToken_get: return 0 + case .p_refreshToken_set(let newValue): return newValue.intValue + case .p_pushToken_get: return 0 + case .p_pushToken_set(let newValue): return newValue.intValue + case .p_appleSignFullName_get: return 0 + case .p_appleSignFullName_set(let newValue): return newValue.intValue + case .p_appleSignEmail_get: return 0 + case .p_appleSignEmail_set(let newValue): return newValue.intValue + case .p_cookiesDate_get: return 0 + case .p_cookiesDate_set(let newValue): return newValue.intValue + case .p_reviewLastShownVersion_get: return 0 + case .p_reviewLastShownVersion_set(let newValue): return newValue.intValue + case .p_lastReviewDate_get: return 0 + case .p_lastReviewDate_set(let newValue): return newValue.intValue + case .p_user_get: return 0 + case .p_user_set(let newValue): return newValue.intValue + case .p_userSettings_get: return 0 + case .p_userSettings_set(let newValue): return newValue.intValue + case .p_resetAppSupportDirectoryUserData_get: return 0 + case .p_resetAppSupportDirectoryUserData_set(let newValue): return newValue.intValue + case .p_useRelativeDates_get: return 0 + case .p_useRelativeDates_set(let newValue): return newValue.intValue + } + } + func assertionName() -> String { + switch self { + case .m_clear: return ".clear()" + case .p_accessToken_get: return "[get] .accessToken" + case .p_accessToken_set: return "[set] .accessToken" + case .p_refreshToken_get: return "[get] .refreshToken" + case .p_refreshToken_set: return "[set] .refreshToken" + case .p_pushToken_get: return "[get] .pushToken" + case .p_pushToken_set: return "[set] .pushToken" + case .p_appleSignFullName_get: return "[get] .appleSignFullName" + case .p_appleSignFullName_set: return "[set] .appleSignFullName" + case .p_appleSignEmail_get: return "[get] .appleSignEmail" + case .p_appleSignEmail_set: return "[set] .appleSignEmail" + case .p_cookiesDate_get: return "[get] .cookiesDate" + case .p_cookiesDate_set: return "[set] .cookiesDate" + case .p_reviewLastShownVersion_get: return "[get] .reviewLastShownVersion" + case .p_reviewLastShownVersion_set: return "[set] .reviewLastShownVersion" + case .p_lastReviewDate_get: return "[get] .lastReviewDate" + case .p_lastReviewDate_set: return "[set] .lastReviewDate" + case .p_user_get: return "[get] .user" + case .p_user_set: return "[set] .user" + case .p_userSettings_get: return "[get] .userSettings" + case .p_userSettings_set: return "[set] .userSettings" + case .p_resetAppSupportDirectoryUserData_get: return "[get] .resetAppSupportDirectoryUserData" + case .p_resetAppSupportDirectoryUserData_set: return "[set] .resetAppSupportDirectoryUserData" + case .p_useRelativeDates_get: return "[get] .useRelativeDates" + case .p_useRelativeDates_set: return "[set] .useRelativeDates" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func accessToken(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_accessToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func refreshToken(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_refreshToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func pushToken(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_pushToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appleSignFullName(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_appleSignFullName_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appleSignEmail(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_appleSignEmail_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func cookiesDate(getter defaultValue: Date?...) -> PropertyStub { + return Given(method: .p_cookiesDate_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func reviewLastShownVersion(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_reviewLastShownVersion_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func lastReviewDate(getter defaultValue: Date?...) -> PropertyStub { + return Given(method: .p_lastReviewDate_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func user(getter defaultValue: DataLayer.User?...) -> PropertyStub { + return Given(method: .p_user_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func userSettings(getter defaultValue: UserSettings?...) -> PropertyStub { + return Given(method: .p_userSettings_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func resetAppSupportDirectoryUserData(getter defaultValue: Bool?...) -> PropertyStub { + return Given(method: .p_resetAppSupportDirectoryUserData_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func useRelativeDates(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_useRelativeDates_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func clear() -> Verify { return Verify(method: .m_clear)} + public static var accessToken: Verify { return Verify(method: .p_accessToken_get) } + public static func accessToken(set newValue: Parameter) -> Verify { return Verify(method: .p_accessToken_set(newValue)) } + public static var refreshToken: Verify { return Verify(method: .p_refreshToken_get) } + public static func refreshToken(set newValue: Parameter) -> Verify { return Verify(method: .p_refreshToken_set(newValue)) } + public static var pushToken: Verify { return Verify(method: .p_pushToken_get) } + public static func pushToken(set newValue: Parameter) -> Verify { return Verify(method: .p_pushToken_set(newValue)) } + public static var appleSignFullName: Verify { return Verify(method: .p_appleSignFullName_get) } + public static func appleSignFullName(set newValue: Parameter) -> Verify { return Verify(method: .p_appleSignFullName_set(newValue)) } + public static var appleSignEmail: Verify { return Verify(method: .p_appleSignEmail_get) } + public static func appleSignEmail(set newValue: Parameter) -> Verify { return Verify(method: .p_appleSignEmail_set(newValue)) } + public static var cookiesDate: Verify { return Verify(method: .p_cookiesDate_get) } + public static func cookiesDate(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesDate_set(newValue)) } + public static var reviewLastShownVersion: Verify { return Verify(method: .p_reviewLastShownVersion_get) } + public static func reviewLastShownVersion(set newValue: Parameter) -> Verify { return Verify(method: .p_reviewLastShownVersion_set(newValue)) } + public static var lastReviewDate: Verify { return Verify(method: .p_lastReviewDate_get) } + public static func lastReviewDate(set newValue: Parameter) -> Verify { return Verify(method: .p_lastReviewDate_set(newValue)) } + public static var user: Verify { return Verify(method: .p_user_get) } + public static func user(set newValue: Parameter) -> Verify { return Verify(method: .p_user_set(newValue)) } + public static var userSettings: Verify { return Verify(method: .p_userSettings_get) } + public static func userSettings(set newValue: Parameter) -> Verify { return Verify(method: .p_userSettings_set(newValue)) } + public static var resetAppSupportDirectoryUserData: Verify { return Verify(method: .p_resetAppSupportDirectoryUserData_get) } + public static func resetAppSupportDirectoryUserData(set newValue: Parameter) -> Verify { return Verify(method: .p_resetAppSupportDirectoryUserData_set(newValue)) } + public static var useRelativeDates: Verify { return Verify(method: .p_useRelativeDates_get) } + public static func useRelativeDates(set newValue: Parameter) -> Verify { return Verify(method: .p_useRelativeDates_set(newValue)) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func clear(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_clear, 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/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index 54425fc98..d1f2c7a15 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -93,23 +93,22 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { return __value } - @discardableResult - open func login(ssoToken: String) async throws -> Core.User { - addInvocation(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) - let perform = methodPerformValue(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) as? (String) -> Void - perform?(`ssoToken`) - var __value: User - do { - __value = try methodReturnValue(.m_login__SSO__username_password(Parameter.value(ssoToken))).casted() - } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for login(username: String, password: String). Use given") - Failure("Stub return value not specified for login(username: String, password: String). Use given") - } catch { - throw error - } - return __value + open func login(ssoToken: String) throws -> User { + addInvocation(.m_login__ssoToken_ssoToken(Parameter.value(`ssoToken`))) + let perform = methodPerformValue(.m_login__ssoToken_ssoToken(Parameter.value(`ssoToken`))) as? (String) -> Void + perform?(`ssoToken`) + var __value: User + do { + __value = try methodReturnValue(.m_login__ssoToken_ssoToken(Parameter.value(`ssoToken`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for login(ssoToken: String). Use given") + Failure("Stub return value not specified for login(ssoToken: String). Use given") + } catch { + throw error + } + return __value } - + open func resetPassword(email: String) throws -> ResetPassword { addInvocation(.m_resetPassword__email_email(Parameter.value(`email`))) let perform = methodPerformValue(.m_resetPassword__email_email(Parameter.value(`email`))) as? (String) -> Void @@ -190,8 +189,8 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { fileprivate enum MethodType { case m_login__username_usernamepassword_password(Parameter, Parameter) - case m_login__SSO__username_password(Parameter) case m_login__externalToken_externalTokenbackend_backend(Parameter, Parameter) + case m_login__ssoToken_ssoToken(Parameter) case m_resetPassword__email_email(Parameter) case m_getCookies__force_force(Parameter) case m_getRegistrationFields @@ -206,17 +205,17 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsPassword, rhs: rhsPassword, with: matcher), lhsPassword, rhsPassword, "password")) return Matcher.ComparisonResult(results) - case (.m_login__SSO__username_password(let lhsJwtToken), .m_login__SSO__username_password(let rhsJwtToken)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsJwtToken, rhs: rhsJwtToken, with: matcher), lhsJwtToken, rhsJwtToken, "jwtToken")) - return Matcher.ComparisonResult(results) - case (.m_login__externalToken_externalTokenbackend_backend(let lhsExternaltoken, let lhsBackend), .m_login__externalToken_externalTokenbackend_backend(let rhsExternaltoken, let rhsBackend)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsExternaltoken, rhs: rhsExternaltoken, with: matcher), lhsExternaltoken, rhsExternaltoken, "externalToken")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBackend, rhs: rhsBackend, with: matcher), lhsBackend, rhsBackend, "backend")) return Matcher.ComparisonResult(results) + case (.m_login__ssoToken_ssoToken(let lhsSsotoken), .m_login__ssoToken_ssoToken(let rhsSsotoken)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSsotoken, rhs: rhsSsotoken, with: matcher), lhsSsotoken, rhsSsotoken, "ssoToken")) + return Matcher.ComparisonResult(results) + case (.m_resetPassword__email_email(let lhsEmail), .m_resetPassword__email_email(let rhsEmail)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEmail, rhs: rhsEmail, with: matcher), lhsEmail, rhsEmail, "email")) @@ -246,8 +245,8 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func intValue() -> Int { switch self { case let .m_login__username_usernamepassword_password(p0, p1): return p0.intValue + p1.intValue - case let .m_login__SSO__username_password(p0): return p0.intValue case let .m_login__externalToken_externalTokenbackend_backend(p0, p1): return p0.intValue + p1.intValue + case let .m_login__ssoToken_ssoToken(p0): return p0.intValue case let .m_resetPassword__email_email(p0): return p0.intValue case let .m_getCookies__force_force(p0): return p0.intValue case .m_getRegistrationFields: return 0 @@ -258,8 +257,8 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func assertionName() -> String { switch self { case .m_login__username_usernamepassword_password: return ".login(username:password:)" - case .m_login__SSO__username_password: return ".loginSSO(username:password:)" case .m_login__externalToken_externalTokenbackend_backend: return ".login(externalToken:backend:)" + case .m_login__ssoToken_ssoToken: return ".login(ssoToken:)" case .m_resetPassword__email_email: return ".resetPassword(email:)" case .m_getCookies__force_force: return ".getCookies(force:)" case .m_getRegistrationFields: return ".getRegistrationFields()" @@ -286,9 +285,8 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func login(externalToken: Parameter, backend: Parameter, willReturn: User...) -> MethodStub { return Given(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`), products: willReturn.map({ StubProduct.return($0 as Any) })) } - @discardableResult - public static func ssoLogin(title: Parameter, willReturn: User...) -> MethodStub { - return Given(method: .m_login__SSO__username_password(`title`), products: willReturn.map({ StubProduct.return($0 as Any) })) + public static func login(ssoToken: Parameter, willReturn: User...) -> MethodStub { + return Given(method: .m_login__ssoToken_ssoToken(`ssoToken`), products: willReturn.map({ StubProduct.return($0 as Any) })) } public static func resetPassword(email: Parameter, willReturn: ResetPassword...) -> MethodStub { return Given(method: .m_resetPassword__email_email(`email`), products: willReturn.map({ StubProduct.return($0 as Any) })) @@ -326,6 +324,16 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { willProduce(stubber) return given } + public static func login(ssoToken: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_login__ssoToken_ssoToken(`ssoToken`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func login(ssoToken: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_login__ssoToken_ssoToken(`ssoToken`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (User).self) + willProduce(stubber) + return given + } public static func resetPassword(email: Parameter, willThrow: Error...) -> MethodStub { return Given(method: .m_resetPassword__email_email(`email`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -382,10 +390,10 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { fileprivate var method: MethodType @discardableResult - public static func login(username: Parameter, password: Parameter) -> Verify { return Verify(method: .m_login__username_usernamepassword_password(`username`, `password`))} - public static func ssoLogin(title: Parameter) -> Verify { return Verify(method: .m_login__SSO__username_password(`title`))} + public static func login(username: Parameter, password: Parameter) -> Verify { return Verify(method: .m_login__username_usernamepassword_password(`username`, `password`))} @discardableResult public static func login(externalToken: Parameter, backend: Parameter) -> Verify { return Verify(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`))} + public static func login(ssoToken: Parameter) -> Verify { return Verify(method: .m_login__ssoToken_ssoToken(`ssoToken`))} public static func resetPassword(email: Parameter) -> Verify { return Verify(method: .m_resetPassword__email_email(`email`))} public static func getCookies(force: Parameter) -> Verify { return Verify(method: .m_getCookies__force_force(`force`))} public static func getRegistrationFields() -> Verify { return Verify(method: .m_getRegistrationFields)} @@ -405,6 +413,9 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func login(externalToken: Parameter, backend: Parameter, perform: @escaping (String, String) -> Void) -> Perform { return Perform(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`), performs: perform) } + public static func login(ssoToken: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_login__ssoToken_ssoToken(`ssoToken`), performs: perform) + } public static func resetPassword(email: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_resetPassword__email_email(`email`), performs: perform) } @@ -612,11 +623,11 @@ open class BaseRouterMock: BaseRouter, Mock { } open func showSSOWebBrowser(title: String) { - addInvocation(.m_showWebBrowser__SSO(Parameter.value(`title`))) - let perform = methodPerformValue(.m_showWebBrowser__SSO(Parameter.value(`title`))) as? (String) -> Void - perform?(`title`) + addInvocation(.m_showSSOWebBrowser__title_title(Parameter.value(`title`))) + let perform = methodPerformValue(.m_showSSOWebBrowser__title_title(Parameter.value(`title`))) as? (String) -> Void + perform?(`title`) } - + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -655,7 +666,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showForgotPasswordScreen case m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(Parameter, Parameter) case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) - case m_showWebBrowser__SSO(Parameter) + case m_showSSOWebBrowser__title_title(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_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) @@ -716,6 +727,11 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUrl, rhs: rhsUrl, with: matcher), lhsUrl, rhsUrl, "url")) return Matcher.ComparisonResult(results) + case (.m_showSSOWebBrowser__title_title(let lhsTitle), .m_showSSOWebBrowser__title_title(let rhsTitle)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTitle, rhs: rhsTitle, with: matcher), lhsTitle, rhsTitle, "title")) + return Matcher.ComparisonResult(results) + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let lhsAlerttitle, let lhsAlertmessage, let lhsPositiveaction, let lhsOnclosetapped, let lhsOktapped, let lhsType), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let rhsAlerttitle, let rhsAlertmessage, let rhsPositiveaction, let rhsOnclosetapped, let rhsOktapped, let rhsType)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) @@ -769,7 +785,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return 0 case let .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(p0, p1): return p0.intValue + p1.intValue case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue - case let .m_showWebBrowser__SSO(p0): return p0.intValue + case let .m_showSSOWebBrowser__title_title(p0): return p0.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_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue @@ -790,7 +806,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen: return ".showDiscoveryScreen(searchQuery:sourceScreen:)" case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" - case .m_showWebBrowser__SSO: return ".showSSOWebBrowser(title:)" + case .m_showSSOWebBrowser__title_title: return ".showSSOWebBrowser(title:)" 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_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" @@ -825,6 +841,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} public static func showDiscoveryScreen(searchQuery: Parameter, sourceScreen: Parameter) -> Verify { return Verify(method: .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(`searchQuery`, `sourceScreen`))} public static func showWebBrowser(title: Parameter, url: Parameter) -> Verify { return Verify(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`))} + public static func showSSOWebBrowser(title: Parameter) -> Verify { return Verify(method: .m_showSSOWebBrowser__title_title(`title`))} 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, completion: Parameter<(() -> Void)?>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`))} @@ -871,6 +888,9 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showWebBrowser(title: Parameter, url: Parameter, perform: @escaping (String, URL) -> Void) -> Perform { return Perform(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`), performs: perform) } + public static func showSSOWebBrowser(title: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_showSSOWebBrowser__title_title(`title`), performs: perform) + } public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } @@ -2110,6 +2130,406 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { } } +// MARK: - CoreStorage + +open class CoreStorageMock: CoreStorage, 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 accessToken: String? { + get { invocations.append(.p_accessToken_get); return __p_accessToken ?? optionalGivenGetterValue(.p_accessToken_get, "CoreStorageMock - stub value for accessToken was not defined") } + set { invocations.append(.p_accessToken_set(.value(newValue))); __p_accessToken = newValue } + } + private var __p_accessToken: (String)? + + public var refreshToken: String? { + get { invocations.append(.p_refreshToken_get); return __p_refreshToken ?? optionalGivenGetterValue(.p_refreshToken_get, "CoreStorageMock - stub value for refreshToken was not defined") } + set { invocations.append(.p_refreshToken_set(.value(newValue))); __p_refreshToken = newValue } + } + private var __p_refreshToken: (String)? + + public var pushToken: String? { + get { invocations.append(.p_pushToken_get); return __p_pushToken ?? optionalGivenGetterValue(.p_pushToken_get, "CoreStorageMock - stub value for pushToken was not defined") } + set { invocations.append(.p_pushToken_set(.value(newValue))); __p_pushToken = newValue } + } + private var __p_pushToken: (String)? + + public var appleSignFullName: String? { + get { invocations.append(.p_appleSignFullName_get); return __p_appleSignFullName ?? optionalGivenGetterValue(.p_appleSignFullName_get, "CoreStorageMock - stub value for appleSignFullName was not defined") } + set { invocations.append(.p_appleSignFullName_set(.value(newValue))); __p_appleSignFullName = newValue } + } + private var __p_appleSignFullName: (String)? + + public var appleSignEmail: String? { + get { invocations.append(.p_appleSignEmail_get); return __p_appleSignEmail ?? optionalGivenGetterValue(.p_appleSignEmail_get, "CoreStorageMock - stub value for appleSignEmail was not defined") } + set { invocations.append(.p_appleSignEmail_set(.value(newValue))); __p_appleSignEmail = newValue } + } + private var __p_appleSignEmail: (String)? + + public var cookiesDate: Date? { + get { invocations.append(.p_cookiesDate_get); return __p_cookiesDate ?? optionalGivenGetterValue(.p_cookiesDate_get, "CoreStorageMock - stub value for cookiesDate was not defined") } + set { invocations.append(.p_cookiesDate_set(.value(newValue))); __p_cookiesDate = newValue } + } + private var __p_cookiesDate: (Date)? + + public var reviewLastShownVersion: String? { + get { invocations.append(.p_reviewLastShownVersion_get); return __p_reviewLastShownVersion ?? optionalGivenGetterValue(.p_reviewLastShownVersion_get, "CoreStorageMock - stub value for reviewLastShownVersion was not defined") } + set { invocations.append(.p_reviewLastShownVersion_set(.value(newValue))); __p_reviewLastShownVersion = newValue } + } + private var __p_reviewLastShownVersion: (String)? + + public var lastReviewDate: Date? { + get { invocations.append(.p_lastReviewDate_get); return __p_lastReviewDate ?? optionalGivenGetterValue(.p_lastReviewDate_get, "CoreStorageMock - stub value for lastReviewDate was not defined") } + set { invocations.append(.p_lastReviewDate_set(.value(newValue))); __p_lastReviewDate = newValue } + } + private var __p_lastReviewDate: (Date)? + + public var user: DataLayer.User? { + get { invocations.append(.p_user_get); return __p_user ?? optionalGivenGetterValue(.p_user_get, "CoreStorageMock - stub value for user was not defined") } + set { invocations.append(.p_user_set(.value(newValue))); __p_user = newValue } + } + private var __p_user: (DataLayer.User)? + + public var userSettings: UserSettings? { + get { invocations.append(.p_userSettings_get); return __p_userSettings ?? optionalGivenGetterValue(.p_userSettings_get, "CoreStorageMock - stub value for userSettings was not defined") } + set { invocations.append(.p_userSettings_set(.value(newValue))); __p_userSettings = newValue } + } + private var __p_userSettings: (UserSettings)? + + public var resetAppSupportDirectoryUserData: Bool? { + get { invocations.append(.p_resetAppSupportDirectoryUserData_get); return __p_resetAppSupportDirectoryUserData ?? optionalGivenGetterValue(.p_resetAppSupportDirectoryUserData_get, "CoreStorageMock - stub value for resetAppSupportDirectoryUserData was not defined") } + set { invocations.append(.p_resetAppSupportDirectoryUserData_set(.value(newValue))); __p_resetAppSupportDirectoryUserData = newValue } + } + private var __p_resetAppSupportDirectoryUserData: (Bool)? + + public var useRelativeDates: Bool { + get { invocations.append(.p_useRelativeDates_get); return __p_useRelativeDates ?? givenGetterValue(.p_useRelativeDates_get, "CoreStorageMock - stub value for useRelativeDates was not defined") } + set { invocations.append(.p_useRelativeDates_set(.value(newValue))); __p_useRelativeDates = newValue } + } + private var __p_useRelativeDates: (Bool)? + + + + + + open func clear() { + addInvocation(.m_clear) + let perform = methodPerformValue(.m_clear) as? () -> Void + perform?() + } + + + fileprivate enum MethodType { + case m_clear + case p_accessToken_get + case p_accessToken_set(Parameter) + case p_refreshToken_get + case p_refreshToken_set(Parameter) + case p_pushToken_get + case p_pushToken_set(Parameter) + case p_appleSignFullName_get + case p_appleSignFullName_set(Parameter) + case p_appleSignEmail_get + case p_appleSignEmail_set(Parameter) + case p_cookiesDate_get + case p_cookiesDate_set(Parameter) + case p_reviewLastShownVersion_get + case p_reviewLastShownVersion_set(Parameter) + case p_lastReviewDate_get + case p_lastReviewDate_set(Parameter) + case p_user_get + case p_user_set(Parameter) + case p_userSettings_get + case p_userSettings_set(Parameter) + case p_resetAppSupportDirectoryUserData_get + case p_resetAppSupportDirectoryUserData_set(Parameter) + case p_useRelativeDates_get + case p_useRelativeDates_set(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_clear, .m_clear): return .match + case (.p_accessToken_get,.p_accessToken_get): return Matcher.ComparisonResult.match + case (.p_accessToken_set(let left),.p_accessToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_refreshToken_get,.p_refreshToken_get): return Matcher.ComparisonResult.match + case (.p_refreshToken_set(let left),.p_refreshToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_pushToken_get,.p_pushToken_get): return Matcher.ComparisonResult.match + case (.p_pushToken_set(let left),.p_pushToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_appleSignFullName_get,.p_appleSignFullName_get): return Matcher.ComparisonResult.match + case (.p_appleSignFullName_set(let left),.p_appleSignFullName_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_appleSignEmail_get,.p_appleSignEmail_get): return Matcher.ComparisonResult.match + case (.p_appleSignEmail_set(let left),.p_appleSignEmail_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_cookiesDate_get,.p_cookiesDate_get): return Matcher.ComparisonResult.match + case (.p_cookiesDate_set(let left),.p_cookiesDate_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_reviewLastShownVersion_get,.p_reviewLastShownVersion_get): return Matcher.ComparisonResult.match + case (.p_reviewLastShownVersion_set(let left),.p_reviewLastShownVersion_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_lastReviewDate_get,.p_lastReviewDate_get): return Matcher.ComparisonResult.match + case (.p_lastReviewDate_set(let left),.p_lastReviewDate_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_user_get,.p_user_get): return Matcher.ComparisonResult.match + case (.p_user_set(let left),.p_user_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_userSettings_get,.p_userSettings_get): return Matcher.ComparisonResult.match + case (.p_userSettings_set(let left),.p_userSettings_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_resetAppSupportDirectoryUserData_get,.p_resetAppSupportDirectoryUserData_get): return Matcher.ComparisonResult.match + case (.p_resetAppSupportDirectoryUserData_set(let left),.p_resetAppSupportDirectoryUserData_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_useRelativeDates_get,.p_useRelativeDates_get): return Matcher.ComparisonResult.match + case (.p_useRelativeDates_set(let left),.p_useRelativeDates_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 .m_clear: return 0 + case .p_accessToken_get: return 0 + case .p_accessToken_set(let newValue): return newValue.intValue + case .p_refreshToken_get: return 0 + case .p_refreshToken_set(let newValue): return newValue.intValue + case .p_pushToken_get: return 0 + case .p_pushToken_set(let newValue): return newValue.intValue + case .p_appleSignFullName_get: return 0 + case .p_appleSignFullName_set(let newValue): return newValue.intValue + case .p_appleSignEmail_get: return 0 + case .p_appleSignEmail_set(let newValue): return newValue.intValue + case .p_cookiesDate_get: return 0 + case .p_cookiesDate_set(let newValue): return newValue.intValue + case .p_reviewLastShownVersion_get: return 0 + case .p_reviewLastShownVersion_set(let newValue): return newValue.intValue + case .p_lastReviewDate_get: return 0 + case .p_lastReviewDate_set(let newValue): return newValue.intValue + case .p_user_get: return 0 + case .p_user_set(let newValue): return newValue.intValue + case .p_userSettings_get: return 0 + case .p_userSettings_set(let newValue): return newValue.intValue + case .p_resetAppSupportDirectoryUserData_get: return 0 + case .p_resetAppSupportDirectoryUserData_set(let newValue): return newValue.intValue + case .p_useRelativeDates_get: return 0 + case .p_useRelativeDates_set(let newValue): return newValue.intValue + } + } + func assertionName() -> String { + switch self { + case .m_clear: return ".clear()" + case .p_accessToken_get: return "[get] .accessToken" + case .p_accessToken_set: return "[set] .accessToken" + case .p_refreshToken_get: return "[get] .refreshToken" + case .p_refreshToken_set: return "[set] .refreshToken" + case .p_pushToken_get: return "[get] .pushToken" + case .p_pushToken_set: return "[set] .pushToken" + case .p_appleSignFullName_get: return "[get] .appleSignFullName" + case .p_appleSignFullName_set: return "[set] .appleSignFullName" + case .p_appleSignEmail_get: return "[get] .appleSignEmail" + case .p_appleSignEmail_set: return "[set] .appleSignEmail" + case .p_cookiesDate_get: return "[get] .cookiesDate" + case .p_cookiesDate_set: return "[set] .cookiesDate" + case .p_reviewLastShownVersion_get: return "[get] .reviewLastShownVersion" + case .p_reviewLastShownVersion_set: return "[set] .reviewLastShownVersion" + case .p_lastReviewDate_get: return "[get] .lastReviewDate" + case .p_lastReviewDate_set: return "[set] .lastReviewDate" + case .p_user_get: return "[get] .user" + case .p_user_set: return "[set] .user" + case .p_userSettings_get: return "[get] .userSettings" + case .p_userSettings_set: return "[set] .userSettings" + case .p_resetAppSupportDirectoryUserData_get: return "[get] .resetAppSupportDirectoryUserData" + case .p_resetAppSupportDirectoryUserData_set: return "[set] .resetAppSupportDirectoryUserData" + case .p_useRelativeDates_get: return "[get] .useRelativeDates" + case .p_useRelativeDates_set: return "[set] .useRelativeDates" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func accessToken(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_accessToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func refreshToken(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_refreshToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func pushToken(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_pushToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appleSignFullName(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_appleSignFullName_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appleSignEmail(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_appleSignEmail_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func cookiesDate(getter defaultValue: Date?...) -> PropertyStub { + return Given(method: .p_cookiesDate_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func reviewLastShownVersion(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_reviewLastShownVersion_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func lastReviewDate(getter defaultValue: Date?...) -> PropertyStub { + return Given(method: .p_lastReviewDate_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func user(getter defaultValue: DataLayer.User?...) -> PropertyStub { + return Given(method: .p_user_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func userSettings(getter defaultValue: UserSettings?...) -> PropertyStub { + return Given(method: .p_userSettings_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func resetAppSupportDirectoryUserData(getter defaultValue: Bool?...) -> PropertyStub { + return Given(method: .p_resetAppSupportDirectoryUserData_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func useRelativeDates(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_useRelativeDates_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func clear() -> Verify { return Verify(method: .m_clear)} + public static var accessToken: Verify { return Verify(method: .p_accessToken_get) } + public static func accessToken(set newValue: Parameter) -> Verify { return Verify(method: .p_accessToken_set(newValue)) } + public static var refreshToken: Verify { return Verify(method: .p_refreshToken_get) } + public static func refreshToken(set newValue: Parameter) -> Verify { return Verify(method: .p_refreshToken_set(newValue)) } + public static var pushToken: Verify { return Verify(method: .p_pushToken_get) } + public static func pushToken(set newValue: Parameter) -> Verify { return Verify(method: .p_pushToken_set(newValue)) } + public static var appleSignFullName: Verify { return Verify(method: .p_appleSignFullName_get) } + public static func appleSignFullName(set newValue: Parameter) -> Verify { return Verify(method: .p_appleSignFullName_set(newValue)) } + public static var appleSignEmail: Verify { return Verify(method: .p_appleSignEmail_get) } + public static func appleSignEmail(set newValue: Parameter) -> Verify { return Verify(method: .p_appleSignEmail_set(newValue)) } + public static var cookiesDate: Verify { return Verify(method: .p_cookiesDate_get) } + public static func cookiesDate(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesDate_set(newValue)) } + public static var reviewLastShownVersion: Verify { return Verify(method: .p_reviewLastShownVersion_get) } + public static func reviewLastShownVersion(set newValue: Parameter) -> Verify { return Verify(method: .p_reviewLastShownVersion_set(newValue)) } + public static var lastReviewDate: Verify { return Verify(method: .p_lastReviewDate_get) } + public static func lastReviewDate(set newValue: Parameter) -> Verify { return Verify(method: .p_lastReviewDate_set(newValue)) } + public static var user: Verify { return Verify(method: .p_user_get) } + public static func user(set newValue: Parameter) -> Verify { return Verify(method: .p_user_set(newValue)) } + public static var userSettings: Verify { return Verify(method: .p_userSettings_get) } + public static func userSettings(set newValue: Parameter) -> Verify { return Verify(method: .p_userSettings_set(newValue)) } + public static var resetAppSupportDirectoryUserData: Verify { return Verify(method: .p_resetAppSupportDirectoryUserData_get) } + public static func resetAppSupportDirectoryUserData(set newValue: Parameter) -> Verify { return Verify(method: .p_resetAppSupportDirectoryUserData_set(newValue)) } + public static var useRelativeDates: Verify { return Verify(method: .p_useRelativeDates_get) } + public static func useRelativeDates(set newValue: Parameter) -> Verify { return Verify(method: .p_useRelativeDates_set(newValue)) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func clear(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_clear, 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 { diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index e443a5af5..1f1237234 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -76,23 +76,6 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { return __value } - @discardableResult - open func login(ssoToken: String) async throws -> Core.User { - addInvocation(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) - let perform = methodPerformValue(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) as? (String) -> Void - perform?(`ssoToken`) - var __value: User - do { - __value = try methodReturnValue(.m_login__SSO__username_password(Parameter.value(ssoToken))).casted() - } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for login(username: String, password: String). Use given") - Failure("Stub return value not specified for login(username: String, password: String). Use given") - } catch { - throw error - } - return __value - } - @discardableResult open func login(externalToken: String, backend: String) throws -> User { addInvocation(.m_login__externalToken_externalTokenbackend_backend(Parameter.value(`externalToken`), Parameter.value(`backend`))) @@ -110,6 +93,22 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { return __value } + open func login(ssoToken: String) throws -> User { + addInvocation(.m_login__ssoToken_ssoToken(Parameter.value(`ssoToken`))) + let perform = methodPerformValue(.m_login__ssoToken_ssoToken(Parameter.value(`ssoToken`))) as? (String) -> Void + perform?(`ssoToken`) + var __value: User + do { + __value = try methodReturnValue(.m_login__ssoToken_ssoToken(Parameter.value(`ssoToken`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for login(ssoToken: String). Use given") + Failure("Stub return value not specified for login(ssoToken: String). Use given") + } catch { + throw error + } + return __value + } + open func resetPassword(email: String) throws -> ResetPassword { addInvocation(.m_resetPassword__email_email(Parameter.value(`email`))) let perform = methodPerformValue(.m_resetPassword__email_email(Parameter.value(`email`))) as? (String) -> Void @@ -190,8 +189,8 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { fileprivate enum MethodType { case m_login__username_usernamepassword_password(Parameter, Parameter) - case m_login__SSO__username_password(Parameter) case m_login__externalToken_externalTokenbackend_backend(Parameter, Parameter) + case m_login__ssoToken_ssoToken(Parameter) case m_resetPassword__email_email(Parameter) case m_getCookies__force_force(Parameter) case m_getRegistrationFields @@ -212,6 +211,11 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBackend, rhs: rhsBackend, with: matcher), lhsBackend, rhsBackend, "backend")) return Matcher.ComparisonResult(results) + case (.m_login__ssoToken_ssoToken(let lhsSsotoken), .m_login__ssoToken_ssoToken(let rhsSsotoken)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSsotoken, rhs: rhsSsotoken, with: matcher), lhsSsotoken, rhsSsotoken, "ssoToken")) + return Matcher.ComparisonResult(results) + case (.m_resetPassword__email_email(let lhsEmail), .m_resetPassword__email_email(let rhsEmail)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEmail, rhs: rhsEmail, with: matcher), lhsEmail, rhsEmail, "email")) @@ -241,8 +245,8 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func intValue() -> Int { switch self { case let .m_login__username_usernamepassword_password(p0, p1): return p0.intValue + p1.intValue - case let .m_login__SSO__username_password(p0): return p0.intValue case let .m_login__externalToken_externalTokenbackend_backend(p0, p1): return p0.intValue + p1.intValue + case let .m_login__ssoToken_ssoToken(p0): return p0.intValue case let .m_resetPassword__email_email(p0): return p0.intValue case let .m_getCookies__force_force(p0): return p0.intValue case .m_getRegistrationFields: return 0 @@ -253,8 +257,8 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func assertionName() -> String { switch self { case .m_login__username_usernamepassword_password: return ".login(username:password:)" - case .m_login__SSO__username_password: return ".loginSSO(username:password:)" case .m_login__externalToken_externalTokenbackend_backend: return ".login(externalToken:backend:)" + case .m_login__ssoToken_ssoToken: return ".login(ssoToken:)" case .m_resetPassword__email_email: return ".resetPassword(email:)" case .m_getCookies__force_force: return ".getCookies(force:)" case .m_getRegistrationFields: return ".getRegistrationFields()" @@ -281,6 +285,9 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func login(externalToken: Parameter, backend: Parameter, willReturn: User...) -> MethodStub { return Given(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func login(ssoToken: Parameter, willReturn: User...) -> MethodStub { + return Given(method: .m_login__ssoToken_ssoToken(`ssoToken`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func resetPassword(email: Parameter, willReturn: ResetPassword...) -> MethodStub { return Given(method: .m_resetPassword__email_email(`email`), products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -317,6 +324,16 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { willProduce(stubber) return given } + public static func login(ssoToken: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_login__ssoToken_ssoToken(`ssoToken`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func login(ssoToken: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_login__ssoToken_ssoToken(`ssoToken`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (User).self) + willProduce(stubber) + return given + } public static func resetPassword(email: Parameter, willThrow: Error...) -> MethodStub { return Given(method: .m_resetPassword__email_email(`email`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -376,6 +393,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func login(username: Parameter, password: Parameter) -> Verify { return Verify(method: .m_login__username_usernamepassword_password(`username`, `password`))} @discardableResult public static func login(externalToken: Parameter, backend: Parameter) -> Verify { return Verify(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`))} + public static func login(ssoToken: Parameter) -> Verify { return Verify(method: .m_login__ssoToken_ssoToken(`ssoToken`))} public static func resetPassword(email: Parameter) -> Verify { return Verify(method: .m_resetPassword__email_email(`email`))} public static func getCookies(force: Parameter) -> Verify { return Verify(method: .m_getCookies__force_force(`force`))} public static func getRegistrationFields() -> Verify { return Verify(method: .m_getRegistrationFields)} @@ -395,6 +413,9 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func login(externalToken: Parameter, backend: Parameter, perform: @escaping (String, String) -> Void) -> Perform { return Perform(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`), performs: perform) } + public static func login(ssoToken: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_login__ssoToken_ssoToken(`ssoToken`), performs: perform) + } public static func resetPassword(email: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_resetPassword__email_email(`email`), performs: perform) } @@ -602,11 +623,11 @@ open class BaseRouterMock: BaseRouter, Mock { } open func showSSOWebBrowser(title: String) { - addInvocation(.m_showWebBrowser__SSO(Parameter.value(`title`))) - let perform = methodPerformValue(.m_showWebBrowser__SSO(Parameter.value(`title`))) as? (String) -> Void - perform?(`title`) + addInvocation(.m_showSSOWebBrowser__title_title(Parameter.value(`title`))) + let perform = methodPerformValue(.m_showSSOWebBrowser__title_title(Parameter.value(`title`))) as? (String) -> Void + perform?(`title`) } - + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -645,7 +666,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showForgotPasswordScreen case m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(Parameter, Parameter) case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) - case m_showWebBrowser__SSO(Parameter) + case m_showSSOWebBrowser__title_title(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_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) @@ -706,6 +727,11 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUrl, rhs: rhsUrl, with: matcher), lhsUrl, rhsUrl, "url")) return Matcher.ComparisonResult(results) + case (.m_showSSOWebBrowser__title_title(let lhsTitle), .m_showSSOWebBrowser__title_title(let rhsTitle)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTitle, rhs: rhsTitle, with: matcher), lhsTitle, rhsTitle, "title")) + return Matcher.ComparisonResult(results) + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let lhsAlerttitle, let lhsAlertmessage, let lhsPositiveaction, let lhsOnclosetapped, let lhsOktapped, let lhsType), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let rhsAlerttitle, let rhsAlertmessage, let rhsPositiveaction, let rhsOnclosetapped, let rhsOktapped, let rhsType)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) @@ -759,7 +785,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return 0 case let .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(p0, p1): return p0.intValue + p1.intValue case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue - case let .m_showWebBrowser__SSO(p0): return p0.intValue + case let .m_showSSOWebBrowser__title_title(p0): return p0.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_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue @@ -780,7 +806,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen: return ".showDiscoveryScreen(searchQuery:sourceScreen:)" case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" - case .m_showWebBrowser__SSO: return ".showSSOWebBrowser(title:)" + case .m_showSSOWebBrowser__title_title: return ".showSSOWebBrowser(title:)" 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_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" @@ -815,6 +841,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} public static func showDiscoveryScreen(searchQuery: Parameter, sourceScreen: Parameter) -> Verify { return Verify(method: .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(`searchQuery`, `sourceScreen`))} public static func showWebBrowser(title: Parameter, url: Parameter) -> Verify { return Verify(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`))} + public static func showSSOWebBrowser(title: Parameter) -> Verify { return Verify(method: .m_showSSOWebBrowser__title_title(`title`))} 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, completion: Parameter<(() -> Void)?>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`))} @@ -861,6 +888,9 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showWebBrowser(title: Parameter, url: Parameter, perform: @escaping (String, URL) -> Void) -> Perform { return Perform(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`), performs: perform) } + public static func showSSOWebBrowser(title: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_showSSOWebBrowser__title_title(`title`), performs: perform) + } public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } @@ -2100,6 +2130,406 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { } } +// MARK: - CoreStorage + +open class CoreStorageMock: CoreStorage, 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 accessToken: String? { + get { invocations.append(.p_accessToken_get); return __p_accessToken ?? optionalGivenGetterValue(.p_accessToken_get, "CoreStorageMock - stub value for accessToken was not defined") } + set { invocations.append(.p_accessToken_set(.value(newValue))); __p_accessToken = newValue } + } + private var __p_accessToken: (String)? + + public var refreshToken: String? { + get { invocations.append(.p_refreshToken_get); return __p_refreshToken ?? optionalGivenGetterValue(.p_refreshToken_get, "CoreStorageMock - stub value for refreshToken was not defined") } + set { invocations.append(.p_refreshToken_set(.value(newValue))); __p_refreshToken = newValue } + } + private var __p_refreshToken: (String)? + + public var pushToken: String? { + get { invocations.append(.p_pushToken_get); return __p_pushToken ?? optionalGivenGetterValue(.p_pushToken_get, "CoreStorageMock - stub value for pushToken was not defined") } + set { invocations.append(.p_pushToken_set(.value(newValue))); __p_pushToken = newValue } + } + private var __p_pushToken: (String)? + + public var appleSignFullName: String? { + get { invocations.append(.p_appleSignFullName_get); return __p_appleSignFullName ?? optionalGivenGetterValue(.p_appleSignFullName_get, "CoreStorageMock - stub value for appleSignFullName was not defined") } + set { invocations.append(.p_appleSignFullName_set(.value(newValue))); __p_appleSignFullName = newValue } + } + private var __p_appleSignFullName: (String)? + + public var appleSignEmail: String? { + get { invocations.append(.p_appleSignEmail_get); return __p_appleSignEmail ?? optionalGivenGetterValue(.p_appleSignEmail_get, "CoreStorageMock - stub value for appleSignEmail was not defined") } + set { invocations.append(.p_appleSignEmail_set(.value(newValue))); __p_appleSignEmail = newValue } + } + private var __p_appleSignEmail: (String)? + + public var cookiesDate: Date? { + get { invocations.append(.p_cookiesDate_get); return __p_cookiesDate ?? optionalGivenGetterValue(.p_cookiesDate_get, "CoreStorageMock - stub value for cookiesDate was not defined") } + set { invocations.append(.p_cookiesDate_set(.value(newValue))); __p_cookiesDate = newValue } + } + private var __p_cookiesDate: (Date)? + + public var reviewLastShownVersion: String? { + get { invocations.append(.p_reviewLastShownVersion_get); return __p_reviewLastShownVersion ?? optionalGivenGetterValue(.p_reviewLastShownVersion_get, "CoreStorageMock - stub value for reviewLastShownVersion was not defined") } + set { invocations.append(.p_reviewLastShownVersion_set(.value(newValue))); __p_reviewLastShownVersion = newValue } + } + private var __p_reviewLastShownVersion: (String)? + + public var lastReviewDate: Date? { + get { invocations.append(.p_lastReviewDate_get); return __p_lastReviewDate ?? optionalGivenGetterValue(.p_lastReviewDate_get, "CoreStorageMock - stub value for lastReviewDate was not defined") } + set { invocations.append(.p_lastReviewDate_set(.value(newValue))); __p_lastReviewDate = newValue } + } + private var __p_lastReviewDate: (Date)? + + public var user: DataLayer.User? { + get { invocations.append(.p_user_get); return __p_user ?? optionalGivenGetterValue(.p_user_get, "CoreStorageMock - stub value for user was not defined") } + set { invocations.append(.p_user_set(.value(newValue))); __p_user = newValue } + } + private var __p_user: (DataLayer.User)? + + public var userSettings: UserSettings? { + get { invocations.append(.p_userSettings_get); return __p_userSettings ?? optionalGivenGetterValue(.p_userSettings_get, "CoreStorageMock - stub value for userSettings was not defined") } + set { invocations.append(.p_userSettings_set(.value(newValue))); __p_userSettings = newValue } + } + private var __p_userSettings: (UserSettings)? + + public var resetAppSupportDirectoryUserData: Bool? { + get { invocations.append(.p_resetAppSupportDirectoryUserData_get); return __p_resetAppSupportDirectoryUserData ?? optionalGivenGetterValue(.p_resetAppSupportDirectoryUserData_get, "CoreStorageMock - stub value for resetAppSupportDirectoryUserData was not defined") } + set { invocations.append(.p_resetAppSupportDirectoryUserData_set(.value(newValue))); __p_resetAppSupportDirectoryUserData = newValue } + } + private var __p_resetAppSupportDirectoryUserData: (Bool)? + + public var useRelativeDates: Bool { + get { invocations.append(.p_useRelativeDates_get); return __p_useRelativeDates ?? givenGetterValue(.p_useRelativeDates_get, "CoreStorageMock - stub value for useRelativeDates was not defined") } + set { invocations.append(.p_useRelativeDates_set(.value(newValue))); __p_useRelativeDates = newValue } + } + private var __p_useRelativeDates: (Bool)? + + + + + + open func clear() { + addInvocation(.m_clear) + let perform = methodPerformValue(.m_clear) as? () -> Void + perform?() + } + + + fileprivate enum MethodType { + case m_clear + case p_accessToken_get + case p_accessToken_set(Parameter) + case p_refreshToken_get + case p_refreshToken_set(Parameter) + case p_pushToken_get + case p_pushToken_set(Parameter) + case p_appleSignFullName_get + case p_appleSignFullName_set(Parameter) + case p_appleSignEmail_get + case p_appleSignEmail_set(Parameter) + case p_cookiesDate_get + case p_cookiesDate_set(Parameter) + case p_reviewLastShownVersion_get + case p_reviewLastShownVersion_set(Parameter) + case p_lastReviewDate_get + case p_lastReviewDate_set(Parameter) + case p_user_get + case p_user_set(Parameter) + case p_userSettings_get + case p_userSettings_set(Parameter) + case p_resetAppSupportDirectoryUserData_get + case p_resetAppSupportDirectoryUserData_set(Parameter) + case p_useRelativeDates_get + case p_useRelativeDates_set(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_clear, .m_clear): return .match + case (.p_accessToken_get,.p_accessToken_get): return Matcher.ComparisonResult.match + case (.p_accessToken_set(let left),.p_accessToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_refreshToken_get,.p_refreshToken_get): return Matcher.ComparisonResult.match + case (.p_refreshToken_set(let left),.p_refreshToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_pushToken_get,.p_pushToken_get): return Matcher.ComparisonResult.match + case (.p_pushToken_set(let left),.p_pushToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_appleSignFullName_get,.p_appleSignFullName_get): return Matcher.ComparisonResult.match + case (.p_appleSignFullName_set(let left),.p_appleSignFullName_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_appleSignEmail_get,.p_appleSignEmail_get): return Matcher.ComparisonResult.match + case (.p_appleSignEmail_set(let left),.p_appleSignEmail_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_cookiesDate_get,.p_cookiesDate_get): return Matcher.ComparisonResult.match + case (.p_cookiesDate_set(let left),.p_cookiesDate_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_reviewLastShownVersion_get,.p_reviewLastShownVersion_get): return Matcher.ComparisonResult.match + case (.p_reviewLastShownVersion_set(let left),.p_reviewLastShownVersion_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_lastReviewDate_get,.p_lastReviewDate_get): return Matcher.ComparisonResult.match + case (.p_lastReviewDate_set(let left),.p_lastReviewDate_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_user_get,.p_user_get): return Matcher.ComparisonResult.match + case (.p_user_set(let left),.p_user_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_userSettings_get,.p_userSettings_get): return Matcher.ComparisonResult.match + case (.p_userSettings_set(let left),.p_userSettings_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_resetAppSupportDirectoryUserData_get,.p_resetAppSupportDirectoryUserData_get): return Matcher.ComparisonResult.match + case (.p_resetAppSupportDirectoryUserData_set(let left),.p_resetAppSupportDirectoryUserData_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_useRelativeDates_get,.p_useRelativeDates_get): return Matcher.ComparisonResult.match + case (.p_useRelativeDates_set(let left),.p_useRelativeDates_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 .m_clear: return 0 + case .p_accessToken_get: return 0 + case .p_accessToken_set(let newValue): return newValue.intValue + case .p_refreshToken_get: return 0 + case .p_refreshToken_set(let newValue): return newValue.intValue + case .p_pushToken_get: return 0 + case .p_pushToken_set(let newValue): return newValue.intValue + case .p_appleSignFullName_get: return 0 + case .p_appleSignFullName_set(let newValue): return newValue.intValue + case .p_appleSignEmail_get: return 0 + case .p_appleSignEmail_set(let newValue): return newValue.intValue + case .p_cookiesDate_get: return 0 + case .p_cookiesDate_set(let newValue): return newValue.intValue + case .p_reviewLastShownVersion_get: return 0 + case .p_reviewLastShownVersion_set(let newValue): return newValue.intValue + case .p_lastReviewDate_get: return 0 + case .p_lastReviewDate_set(let newValue): return newValue.intValue + case .p_user_get: return 0 + case .p_user_set(let newValue): return newValue.intValue + case .p_userSettings_get: return 0 + case .p_userSettings_set(let newValue): return newValue.intValue + case .p_resetAppSupportDirectoryUserData_get: return 0 + case .p_resetAppSupportDirectoryUserData_set(let newValue): return newValue.intValue + case .p_useRelativeDates_get: return 0 + case .p_useRelativeDates_set(let newValue): return newValue.intValue + } + } + func assertionName() -> String { + switch self { + case .m_clear: return ".clear()" + case .p_accessToken_get: return "[get] .accessToken" + case .p_accessToken_set: return "[set] .accessToken" + case .p_refreshToken_get: return "[get] .refreshToken" + case .p_refreshToken_set: return "[set] .refreshToken" + case .p_pushToken_get: return "[get] .pushToken" + case .p_pushToken_set: return "[set] .pushToken" + case .p_appleSignFullName_get: return "[get] .appleSignFullName" + case .p_appleSignFullName_set: return "[set] .appleSignFullName" + case .p_appleSignEmail_get: return "[get] .appleSignEmail" + case .p_appleSignEmail_set: return "[set] .appleSignEmail" + case .p_cookiesDate_get: return "[get] .cookiesDate" + case .p_cookiesDate_set: return "[set] .cookiesDate" + case .p_reviewLastShownVersion_get: return "[get] .reviewLastShownVersion" + case .p_reviewLastShownVersion_set: return "[set] .reviewLastShownVersion" + case .p_lastReviewDate_get: return "[get] .lastReviewDate" + case .p_lastReviewDate_set: return "[set] .lastReviewDate" + case .p_user_get: return "[get] .user" + case .p_user_set: return "[set] .user" + case .p_userSettings_get: return "[get] .userSettings" + case .p_userSettings_set: return "[set] .userSettings" + case .p_resetAppSupportDirectoryUserData_get: return "[get] .resetAppSupportDirectoryUserData" + case .p_resetAppSupportDirectoryUserData_set: return "[set] .resetAppSupportDirectoryUserData" + case .p_useRelativeDates_get: return "[get] .useRelativeDates" + case .p_useRelativeDates_set: return "[set] .useRelativeDates" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func accessToken(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_accessToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func refreshToken(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_refreshToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func pushToken(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_pushToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appleSignFullName(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_appleSignFullName_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appleSignEmail(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_appleSignEmail_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func cookiesDate(getter defaultValue: Date?...) -> PropertyStub { + return Given(method: .p_cookiesDate_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func reviewLastShownVersion(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_reviewLastShownVersion_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func lastReviewDate(getter defaultValue: Date?...) -> PropertyStub { + return Given(method: .p_lastReviewDate_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func user(getter defaultValue: DataLayer.User?...) -> PropertyStub { + return Given(method: .p_user_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func userSettings(getter defaultValue: UserSettings?...) -> PropertyStub { + return Given(method: .p_userSettings_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func resetAppSupportDirectoryUserData(getter defaultValue: Bool?...) -> PropertyStub { + return Given(method: .p_resetAppSupportDirectoryUserData_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func useRelativeDates(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_useRelativeDates_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func clear() -> Verify { return Verify(method: .m_clear)} + public static var accessToken: Verify { return Verify(method: .p_accessToken_get) } + public static func accessToken(set newValue: Parameter) -> Verify { return Verify(method: .p_accessToken_set(newValue)) } + public static var refreshToken: Verify { return Verify(method: .p_refreshToken_get) } + public static func refreshToken(set newValue: Parameter) -> Verify { return Verify(method: .p_refreshToken_set(newValue)) } + public static var pushToken: Verify { return Verify(method: .p_pushToken_get) } + public static func pushToken(set newValue: Parameter) -> Verify { return Verify(method: .p_pushToken_set(newValue)) } + public static var appleSignFullName: Verify { return Verify(method: .p_appleSignFullName_get) } + public static func appleSignFullName(set newValue: Parameter) -> Verify { return Verify(method: .p_appleSignFullName_set(newValue)) } + public static var appleSignEmail: Verify { return Verify(method: .p_appleSignEmail_get) } + public static func appleSignEmail(set newValue: Parameter) -> Verify { return Verify(method: .p_appleSignEmail_set(newValue)) } + public static var cookiesDate: Verify { return Verify(method: .p_cookiesDate_get) } + public static func cookiesDate(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesDate_set(newValue)) } + public static var reviewLastShownVersion: Verify { return Verify(method: .p_reviewLastShownVersion_get) } + public static func reviewLastShownVersion(set newValue: Parameter) -> Verify { return Verify(method: .p_reviewLastShownVersion_set(newValue)) } + public static var lastReviewDate: Verify { return Verify(method: .p_lastReviewDate_get) } + public static func lastReviewDate(set newValue: Parameter) -> Verify { return Verify(method: .p_lastReviewDate_set(newValue)) } + public static var user: Verify { return Verify(method: .p_user_get) } + public static func user(set newValue: Parameter) -> Verify { return Verify(method: .p_user_set(newValue)) } + public static var userSettings: Verify { return Verify(method: .p_userSettings_get) } + public static func userSettings(set newValue: Parameter) -> Verify { return Verify(method: .p_userSettings_set(newValue)) } + public static var resetAppSupportDirectoryUserData: Verify { return Verify(method: .p_resetAppSupportDirectoryUserData_get) } + public static func resetAppSupportDirectoryUserData(set newValue: Parameter) -> Verify { return Verify(method: .p_resetAppSupportDirectoryUserData_set(newValue)) } + public static var useRelativeDates: Verify { return Verify(method: .p_useRelativeDates_get) } + public static func useRelativeDates(set newValue: Parameter) -> Verify { return Verify(method: .p_useRelativeDates_set(newValue)) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func clear(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_clear, 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 { @@ -3339,11 +3769,11 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { } open func showSSOWebBrowser(title: String) { - addInvocation(.m_showWebBrowser__SSO(Parameter.value(`title`))) - let perform = methodPerformValue(.m_showWebBrowser__SSO(Parameter.value(`title`))) as? (String) -> Void - perform?(`title`) + addInvocation(.m_showSSOWebBrowser__title_title(Parameter.value(`title`))) + let perform = methodPerformValue(.m_showSSOWebBrowser__title_title(Parameter.value(`title`))) as? (String) -> Void + perform?(`title`) } - + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -3388,7 +3818,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { case m_showForgotPasswordScreen case m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(Parameter, Parameter) case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) - case m_showWebBrowser__SSO(Parameter) + case m_showSSOWebBrowser__title_title(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_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) @@ -3494,6 +3924,11 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUrl, rhs: rhsUrl, with: matcher), lhsUrl, rhsUrl, "url")) return Matcher.ComparisonResult(results) + case (.m_showSSOWebBrowser__title_title(let lhsTitle), .m_showSSOWebBrowser__title_title(let rhsTitle)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTitle, rhs: rhsTitle, with: matcher), lhsTitle, rhsTitle, "title")) + return Matcher.ComparisonResult(results) + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let lhsAlerttitle, let lhsAlertmessage, let lhsPositiveaction, let lhsOnclosetapped, let lhsOktapped, let lhsType), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let rhsAlerttitle, let rhsAlertmessage, let rhsPositiveaction, let rhsOnclosetapped, let rhsOktapped, let rhsType)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) @@ -3553,7 +3988,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { case .m_showForgotPasswordScreen: return 0 case let .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(p0, p1): return p0.intValue + p1.intValue case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue - case let .m_showWebBrowser__SSO(p0): return p0.intValue + case let .m_showSSOWebBrowser__title_title(p0): return p0.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_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue @@ -3580,7 +4015,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen: return ".showDiscoveryScreen(searchQuery:sourceScreen:)" case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" - case .m_showWebBrowser__SSO: return ".showSSOWebBrowser(title:)" + case .m_showSSOWebBrowser__title_title: return ".showSSOWebBrowser(title:)" 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_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" @@ -3621,6 +4056,7 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} public static func showDiscoveryScreen(searchQuery: Parameter, sourceScreen: Parameter) -> Verify { return Verify(method: .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(`searchQuery`, `sourceScreen`))} public static func showWebBrowser(title: Parameter, url: Parameter) -> Verify { return Verify(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`))} + public static func showSSOWebBrowser(title: Parameter) -> Verify { return Verify(method: .m_showSSOWebBrowser__title_title(`title`))} 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, completion: Parameter<(() -> Void)?>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`))} @@ -3685,6 +4121,9 @@ open class DiscussionRouterMock: DiscussionRouter, Mock { public static func showWebBrowser(title: Parameter, url: Parameter, perform: @escaping (String, URL) -> Void) -> Perform { return Perform(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`), performs: perform) } + public static func showSSOWebBrowser(title: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_showSSOWebBrowser__title_title(`title`), performs: perform) + } public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } diff --git a/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModelTests.swift b/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModelTests.swift index 33a98349c..d6c82f7aa 100644 --- a/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/DiscussionTopics/DiscussionSearchTopicsViewModelTests.swift @@ -17,9 +17,10 @@ final class DiscussionSearchTopicsViewModelTests: XCTestCase { func testSearchSuccess() async throws { let interactor = DiscussionInteractorProtocolMock() let router = DiscussionRouterMock() + let storage = CoreStorageMock() let viewModel = DiscussionSearchTopicsViewModel(courseID: "123", interactor: interactor, - storage: CoreStorageMock(), + storage: storage, router: router, debounce: .test) @@ -48,7 +49,7 @@ final class DiscussionSearchTopicsViewModelTests: XCTestCase { numPages: 1) ] ) - + Given(storage, .useRelativeDates(getter: false)) Given(interactor, .searchThreads(courseID: .any, searchText: .any, pageNumber: .any, willReturn: items)) viewModel.searchText = "Test" diff --git a/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift b/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift index 9b9275d55..e55d82227 100644 --- a/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift +++ b/Discussion/DiscussionTests/Presentation/Posts/PostViewModelTests.swift @@ -114,15 +114,18 @@ final class PostViewModelTests: XCTestCase { interactor = DiscussionInteractorProtocolMock() router = DiscussionRouterMock() config = ConfigMock() + let storage = CoreStorageMock() + + Given(storage, .useRelativeDates(getter: false)) + viewModel = PostsViewModel( interactor: interactor, router: router, config: config, - storage: CoreStorageMock() + storage: storage ) } - func testGetThreadListSuccess() async throws { var result = false diff --git a/Podfile b/Podfile index 33bbe344f..929bdb7b9 100644 --- a/Podfile +++ b/Podfile @@ -25,6 +25,10 @@ abstract_target "App" do pod 'SwiftUIIntrospect', '~> 1.3' pod 'Kingfisher', '~> 8.0' pod 'Swinject', '2.9.1' + + target 'CoreTests' do + pod 'SwiftyMocky', :git => 'https://github.com/MakeAWishFoundation/SwiftyMocky.git', :tag => '4.2.0' + end end target "Authorization" do diff --git a/Podfile.lock b/Podfile.lock index 43f9058d6..edbba665e 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -59,6 +59,6 @@ SPEC CHECKSUMS: SwiftyMocky: c5e96e4ff76ec6dbf5a5941aeb039b5a546954a0 Swinject: a827d508c6270da03ec74e558e728917a888fa9b -PODFILE CHECKSUM: 1873e8ac2c331deb6c08b61ef4cd18d56e060c40 +PODFILE CHECKSUM: a4fdd0279f24855bc71cef3096c188e41977d96c COCOAPODS: 1.15.2 diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 6d51643fe..f6d738dff 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -62,35 +62,18 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { @discardableResult open func login(username: String, password: String) throws -> User { addInvocation(.m_login__username_usernamepassword_password(Parameter.value(`username`), Parameter.value(`password`))) - let perform = methodPerformValue(.m_login__username_usernamepassword_password(Parameter.value(`username`), Parameter.value(`password`))) as? (String, String) -> Void - perform?(`username`, `password`) - var __value: User - do { - __value = try methodReturnValue(.m_login__username_usernamepassword_password(Parameter.value(`username`), Parameter.value(`password`))).casted() - } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for login(username: String, password: String). Use given") - Failure("Stub return value not specified for login(username: String, password: String). Use given") - } catch { - throw error - } - return __value - } - - @discardableResult - open func login(ssoToken: String) async throws -> Core.User { - addInvocation(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) - let perform = methodPerformValue(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) as? (String) -> Void - perform?(`ssoToken`) - var __value: User - do { - __value = try methodReturnValue(.m_login__SSO__username_password(Parameter.value(ssoToken))).casted() - } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for login(username: String, password: String). Use given") - Failure("Stub return value not specified for login(username: String, password: String). Use given") - } catch { - throw error - } - return __value + let perform = methodPerformValue(.m_login__username_usernamepassword_password(Parameter.value(`username`), Parameter.value(`password`))) as? (String, String) -> Void + perform?(`username`, `password`) + var __value: User + do { + __value = try methodReturnValue(.m_login__username_usernamepassword_password(Parameter.value(`username`), Parameter.value(`password`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for login(username: String, password: String). Use given") + Failure("Stub return value not specified for login(username: String, password: String). Use given") + } catch { + throw error + } + return __value } @discardableResult @@ -110,6 +93,22 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { return __value } + open func login(ssoToken: String) throws -> User { + addInvocation(.m_login__ssoToken_ssoToken(Parameter.value(`ssoToken`))) + let perform = methodPerformValue(.m_login__ssoToken_ssoToken(Parameter.value(`ssoToken`))) as? (String) -> Void + perform?(`ssoToken`) + var __value: User + do { + __value = try methodReturnValue(.m_login__ssoToken_ssoToken(Parameter.value(`ssoToken`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for login(ssoToken: String). Use given") + Failure("Stub return value not specified for login(ssoToken: String). Use given") + } catch { + throw error + } + return __value + } + open func resetPassword(email: String) throws -> ResetPassword { addInvocation(.m_resetPassword__email_email(Parameter.value(`email`))) let perform = methodPerformValue(.m_resetPassword__email_email(Parameter.value(`email`))) as? (String) -> Void @@ -190,8 +189,8 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { fileprivate enum MethodType { case m_login__username_usernamepassword_password(Parameter, Parameter) - case m_login__SSO__username_password(Parameter) case m_login__externalToken_externalTokenbackend_backend(Parameter, Parameter) + case m_login__ssoToken_ssoToken(Parameter) case m_resetPassword__email_email(Parameter) case m_getCookies__force_force(Parameter) case m_getRegistrationFields @@ -212,6 +211,11 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBackend, rhs: rhsBackend, with: matcher), lhsBackend, rhsBackend, "backend")) return Matcher.ComparisonResult(results) + case (.m_login__ssoToken_ssoToken(let lhsSsotoken), .m_login__ssoToken_ssoToken(let rhsSsotoken)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSsotoken, rhs: rhsSsotoken, with: matcher), lhsSsotoken, rhsSsotoken, "ssoToken")) + return Matcher.ComparisonResult(results) + case (.m_resetPassword__email_email(let lhsEmail), .m_resetPassword__email_email(let rhsEmail)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEmail, rhs: rhsEmail, with: matcher), lhsEmail, rhsEmail, "email")) @@ -241,8 +245,8 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func intValue() -> Int { switch self { case let .m_login__username_usernamepassword_password(p0, p1): return p0.intValue + p1.intValue - case let .m_login__SSO__username_password(p0): return p0.intValue case let .m_login__externalToken_externalTokenbackend_backend(p0, p1): return p0.intValue + p1.intValue + case let .m_login__ssoToken_ssoToken(p0): return p0.intValue case let .m_resetPassword__email_email(p0): return p0.intValue case let .m_getCookies__force_force(p0): return p0.intValue case .m_getRegistrationFields: return 0 @@ -253,8 +257,8 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func assertionName() -> String { switch self { case .m_login__username_usernamepassword_password: return ".login(username:password:)" - case .m_login__SSO__username_password: return ".loginSSO(username:password:)" case .m_login__externalToken_externalTokenbackend_backend: return ".login(externalToken:backend:)" + case .m_login__ssoToken_ssoToken: return ".login(ssoToken:)" case .m_resetPassword__email_email: return ".resetPassword(email:)" case .m_getCookies__force_force: return ".getCookies(force:)" case .m_getRegistrationFields: return ".getRegistrationFields()" @@ -281,6 +285,9 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func login(externalToken: Parameter, backend: Parameter, willReturn: User...) -> MethodStub { return Given(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`), products: willReturn.map({ StubProduct.return($0 as Any) })) } + public static func login(ssoToken: Parameter, willReturn: User...) -> MethodStub { + return Given(method: .m_login__ssoToken_ssoToken(`ssoToken`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } public static func resetPassword(email: Parameter, willReturn: ResetPassword...) -> MethodStub { return Given(method: .m_resetPassword__email_email(`email`), products: willReturn.map({ StubProduct.return($0 as Any) })) } @@ -317,6 +324,16 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { willProduce(stubber) return given } + public static func login(ssoToken: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_login__ssoToken_ssoToken(`ssoToken`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func login(ssoToken: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_login__ssoToken_ssoToken(`ssoToken`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (User).self) + willProduce(stubber) + return given + } public static func resetPassword(email: Parameter, willThrow: Error...) -> MethodStub { return Given(method: .m_resetPassword__email_email(`email`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -376,6 +393,7 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func login(username: Parameter, password: Parameter) -> Verify { return Verify(method: .m_login__username_usernamepassword_password(`username`, `password`))} @discardableResult public static func login(externalToken: Parameter, backend: Parameter) -> Verify { return Verify(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`))} + public static func login(ssoToken: Parameter) -> Verify { return Verify(method: .m_login__ssoToken_ssoToken(`ssoToken`))} public static func resetPassword(email: Parameter) -> Verify { return Verify(method: .m_resetPassword__email_email(`email`))} public static func getCookies(force: Parameter) -> Verify { return Verify(method: .m_getCookies__force_force(`force`))} public static func getRegistrationFields() -> Verify { return Verify(method: .m_getRegistrationFields)} @@ -395,6 +413,9 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func login(externalToken: Parameter, backend: Parameter, perform: @escaping (String, String) -> Void) -> Perform { return Perform(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`), performs: perform) } + public static func login(ssoToken: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_login__ssoToken_ssoToken(`ssoToken`), performs: perform) + } public static func resetPassword(email: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_resetPassword__email_email(`email`), performs: perform) } @@ -602,11 +623,11 @@ open class BaseRouterMock: BaseRouter, Mock { } open func showSSOWebBrowser(title: String) { - addInvocation(.m_showWebBrowser__SSO(Parameter.value(`title`))) - let perform = methodPerformValue(.m_showWebBrowser__SSO(Parameter.value(`title`))) as? (String) -> Void - perform?(`title`) + addInvocation(.m_showSSOWebBrowser__title_title(Parameter.value(`title`))) + let perform = methodPerformValue(.m_showSSOWebBrowser__title_title(Parameter.value(`title`))) as? (String) -> Void + perform?(`title`) } - + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -645,7 +666,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showForgotPasswordScreen case m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(Parameter, Parameter) case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) - case m_showWebBrowser__SSO(Parameter) + case m_showSSOWebBrowser__title_title(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_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) @@ -706,6 +727,11 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUrl, rhs: rhsUrl, with: matcher), lhsUrl, rhsUrl, "url")) return Matcher.ComparisonResult(results) + case (.m_showSSOWebBrowser__title_title(let lhsTitle), .m_showSSOWebBrowser__title_title(let rhsTitle)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTitle, rhs: rhsTitle, with: matcher), lhsTitle, rhsTitle, "title")) + return Matcher.ComparisonResult(results) + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let lhsAlerttitle, let lhsAlertmessage, let lhsPositiveaction, let lhsOnclosetapped, let lhsOktapped, let lhsType), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let rhsAlerttitle, let rhsAlertmessage, let rhsPositiveaction, let rhsOnclosetapped, let rhsOktapped, let rhsType)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) @@ -759,7 +785,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return 0 case let .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(p0, p1): return p0.intValue + p1.intValue case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue - case let .m_showWebBrowser__SSO(p0): return p0.intValue + case let .m_showSSOWebBrowser__title_title(p0): return p0.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_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue @@ -780,7 +806,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen: return ".showDiscoveryScreen(searchQuery:sourceScreen:)" case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" - case .m_showWebBrowser__SSO: return ".showSSOWebBrowser(title:)" + case .m_showSSOWebBrowser__title_title: return ".showSSOWebBrowser(title:)" 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_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" @@ -815,6 +841,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} public static func showDiscoveryScreen(searchQuery: Parameter, sourceScreen: Parameter) -> Verify { return Verify(method: .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(`searchQuery`, `sourceScreen`))} public static func showWebBrowser(title: Parameter, url: Parameter) -> Verify { return Verify(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`))} + public static func showSSOWebBrowser(title: Parameter) -> Verify { return Verify(method: .m_showSSOWebBrowser__title_title(`title`))} 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, completion: Parameter<(() -> Void)?>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`))} @@ -861,6 +888,9 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showWebBrowser(title: Parameter, url: Parameter, perform: @escaping (String, URL) -> Void) -> Perform { return Perform(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`), performs: perform) } + public static func showSSOWebBrowser(title: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_showSSOWebBrowser__title_title(`title`), performs: perform) + } public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } @@ -2100,6 +2130,406 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { } } +// MARK: - CoreStorage + +open class CoreStorageMock: CoreStorage, 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 accessToken: String? { + get { invocations.append(.p_accessToken_get); return __p_accessToken ?? optionalGivenGetterValue(.p_accessToken_get, "CoreStorageMock - stub value for accessToken was not defined") } + set { invocations.append(.p_accessToken_set(.value(newValue))); __p_accessToken = newValue } + } + private var __p_accessToken: (String)? + + public var refreshToken: String? { + get { invocations.append(.p_refreshToken_get); return __p_refreshToken ?? optionalGivenGetterValue(.p_refreshToken_get, "CoreStorageMock - stub value for refreshToken was not defined") } + set { invocations.append(.p_refreshToken_set(.value(newValue))); __p_refreshToken = newValue } + } + private var __p_refreshToken: (String)? + + public var pushToken: String? { + get { invocations.append(.p_pushToken_get); return __p_pushToken ?? optionalGivenGetterValue(.p_pushToken_get, "CoreStorageMock - stub value for pushToken was not defined") } + set { invocations.append(.p_pushToken_set(.value(newValue))); __p_pushToken = newValue } + } + private var __p_pushToken: (String)? + + public var appleSignFullName: String? { + get { invocations.append(.p_appleSignFullName_get); return __p_appleSignFullName ?? optionalGivenGetterValue(.p_appleSignFullName_get, "CoreStorageMock - stub value for appleSignFullName was not defined") } + set { invocations.append(.p_appleSignFullName_set(.value(newValue))); __p_appleSignFullName = newValue } + } + private var __p_appleSignFullName: (String)? + + public var appleSignEmail: String? { + get { invocations.append(.p_appleSignEmail_get); return __p_appleSignEmail ?? optionalGivenGetterValue(.p_appleSignEmail_get, "CoreStorageMock - stub value for appleSignEmail was not defined") } + set { invocations.append(.p_appleSignEmail_set(.value(newValue))); __p_appleSignEmail = newValue } + } + private var __p_appleSignEmail: (String)? + + public var cookiesDate: Date? { + get { invocations.append(.p_cookiesDate_get); return __p_cookiesDate ?? optionalGivenGetterValue(.p_cookiesDate_get, "CoreStorageMock - stub value for cookiesDate was not defined") } + set { invocations.append(.p_cookiesDate_set(.value(newValue))); __p_cookiesDate = newValue } + } + private var __p_cookiesDate: (Date)? + + public var reviewLastShownVersion: String? { + get { invocations.append(.p_reviewLastShownVersion_get); return __p_reviewLastShownVersion ?? optionalGivenGetterValue(.p_reviewLastShownVersion_get, "CoreStorageMock - stub value for reviewLastShownVersion was not defined") } + set { invocations.append(.p_reviewLastShownVersion_set(.value(newValue))); __p_reviewLastShownVersion = newValue } + } + private var __p_reviewLastShownVersion: (String)? + + public var lastReviewDate: Date? { + get { invocations.append(.p_lastReviewDate_get); return __p_lastReviewDate ?? optionalGivenGetterValue(.p_lastReviewDate_get, "CoreStorageMock - stub value for lastReviewDate was not defined") } + set { invocations.append(.p_lastReviewDate_set(.value(newValue))); __p_lastReviewDate = newValue } + } + private var __p_lastReviewDate: (Date)? + + public var user: DataLayer.User? { + get { invocations.append(.p_user_get); return __p_user ?? optionalGivenGetterValue(.p_user_get, "CoreStorageMock - stub value for user was not defined") } + set { invocations.append(.p_user_set(.value(newValue))); __p_user = newValue } + } + private var __p_user: (DataLayer.User)? + + public var userSettings: UserSettings? { + get { invocations.append(.p_userSettings_get); return __p_userSettings ?? optionalGivenGetterValue(.p_userSettings_get, "CoreStorageMock - stub value for userSettings was not defined") } + set { invocations.append(.p_userSettings_set(.value(newValue))); __p_userSettings = newValue } + } + private var __p_userSettings: (UserSettings)? + + public var resetAppSupportDirectoryUserData: Bool? { + get { invocations.append(.p_resetAppSupportDirectoryUserData_get); return __p_resetAppSupportDirectoryUserData ?? optionalGivenGetterValue(.p_resetAppSupportDirectoryUserData_get, "CoreStorageMock - stub value for resetAppSupportDirectoryUserData was not defined") } + set { invocations.append(.p_resetAppSupportDirectoryUserData_set(.value(newValue))); __p_resetAppSupportDirectoryUserData = newValue } + } + private var __p_resetAppSupportDirectoryUserData: (Bool)? + + public var useRelativeDates: Bool { + get { invocations.append(.p_useRelativeDates_get); return __p_useRelativeDates ?? givenGetterValue(.p_useRelativeDates_get, "CoreStorageMock - stub value for useRelativeDates was not defined") } + set { invocations.append(.p_useRelativeDates_set(.value(newValue))); __p_useRelativeDates = newValue } + } + private var __p_useRelativeDates: (Bool)? + + + + + + open func clear() { + addInvocation(.m_clear) + let perform = methodPerformValue(.m_clear) as? () -> Void + perform?() + } + + + fileprivate enum MethodType { + case m_clear + case p_accessToken_get + case p_accessToken_set(Parameter) + case p_refreshToken_get + case p_refreshToken_set(Parameter) + case p_pushToken_get + case p_pushToken_set(Parameter) + case p_appleSignFullName_get + case p_appleSignFullName_set(Parameter) + case p_appleSignEmail_get + case p_appleSignEmail_set(Parameter) + case p_cookiesDate_get + case p_cookiesDate_set(Parameter) + case p_reviewLastShownVersion_get + case p_reviewLastShownVersion_set(Parameter) + case p_lastReviewDate_get + case p_lastReviewDate_set(Parameter) + case p_user_get + case p_user_set(Parameter) + case p_userSettings_get + case p_userSettings_set(Parameter) + case p_resetAppSupportDirectoryUserData_get + case p_resetAppSupportDirectoryUserData_set(Parameter) + case p_useRelativeDates_get + case p_useRelativeDates_set(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_clear, .m_clear): return .match + case (.p_accessToken_get,.p_accessToken_get): return Matcher.ComparisonResult.match + case (.p_accessToken_set(let left),.p_accessToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_refreshToken_get,.p_refreshToken_get): return Matcher.ComparisonResult.match + case (.p_refreshToken_set(let left),.p_refreshToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_pushToken_get,.p_pushToken_get): return Matcher.ComparisonResult.match + case (.p_pushToken_set(let left),.p_pushToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_appleSignFullName_get,.p_appleSignFullName_get): return Matcher.ComparisonResult.match + case (.p_appleSignFullName_set(let left),.p_appleSignFullName_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_appleSignEmail_get,.p_appleSignEmail_get): return Matcher.ComparisonResult.match + case (.p_appleSignEmail_set(let left),.p_appleSignEmail_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_cookiesDate_get,.p_cookiesDate_get): return Matcher.ComparisonResult.match + case (.p_cookiesDate_set(let left),.p_cookiesDate_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_reviewLastShownVersion_get,.p_reviewLastShownVersion_get): return Matcher.ComparisonResult.match + case (.p_reviewLastShownVersion_set(let left),.p_reviewLastShownVersion_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_lastReviewDate_get,.p_lastReviewDate_get): return Matcher.ComparisonResult.match + case (.p_lastReviewDate_set(let left),.p_lastReviewDate_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_user_get,.p_user_get): return Matcher.ComparisonResult.match + case (.p_user_set(let left),.p_user_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_userSettings_get,.p_userSettings_get): return Matcher.ComparisonResult.match + case (.p_userSettings_set(let left),.p_userSettings_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_resetAppSupportDirectoryUserData_get,.p_resetAppSupportDirectoryUserData_get): return Matcher.ComparisonResult.match + case (.p_resetAppSupportDirectoryUserData_set(let left),.p_resetAppSupportDirectoryUserData_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_useRelativeDates_get,.p_useRelativeDates_get): return Matcher.ComparisonResult.match + case (.p_useRelativeDates_set(let left),.p_useRelativeDates_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 .m_clear: return 0 + case .p_accessToken_get: return 0 + case .p_accessToken_set(let newValue): return newValue.intValue + case .p_refreshToken_get: return 0 + case .p_refreshToken_set(let newValue): return newValue.intValue + case .p_pushToken_get: return 0 + case .p_pushToken_set(let newValue): return newValue.intValue + case .p_appleSignFullName_get: return 0 + case .p_appleSignFullName_set(let newValue): return newValue.intValue + case .p_appleSignEmail_get: return 0 + case .p_appleSignEmail_set(let newValue): return newValue.intValue + case .p_cookiesDate_get: return 0 + case .p_cookiesDate_set(let newValue): return newValue.intValue + case .p_reviewLastShownVersion_get: return 0 + case .p_reviewLastShownVersion_set(let newValue): return newValue.intValue + case .p_lastReviewDate_get: return 0 + case .p_lastReviewDate_set(let newValue): return newValue.intValue + case .p_user_get: return 0 + case .p_user_set(let newValue): return newValue.intValue + case .p_userSettings_get: return 0 + case .p_userSettings_set(let newValue): return newValue.intValue + case .p_resetAppSupportDirectoryUserData_get: return 0 + case .p_resetAppSupportDirectoryUserData_set(let newValue): return newValue.intValue + case .p_useRelativeDates_get: return 0 + case .p_useRelativeDates_set(let newValue): return newValue.intValue + } + } + func assertionName() -> String { + switch self { + case .m_clear: return ".clear()" + case .p_accessToken_get: return "[get] .accessToken" + case .p_accessToken_set: return "[set] .accessToken" + case .p_refreshToken_get: return "[get] .refreshToken" + case .p_refreshToken_set: return "[set] .refreshToken" + case .p_pushToken_get: return "[get] .pushToken" + case .p_pushToken_set: return "[set] .pushToken" + case .p_appleSignFullName_get: return "[get] .appleSignFullName" + case .p_appleSignFullName_set: return "[set] .appleSignFullName" + case .p_appleSignEmail_get: return "[get] .appleSignEmail" + case .p_appleSignEmail_set: return "[set] .appleSignEmail" + case .p_cookiesDate_get: return "[get] .cookiesDate" + case .p_cookiesDate_set: return "[set] .cookiesDate" + case .p_reviewLastShownVersion_get: return "[get] .reviewLastShownVersion" + case .p_reviewLastShownVersion_set: return "[set] .reviewLastShownVersion" + case .p_lastReviewDate_get: return "[get] .lastReviewDate" + case .p_lastReviewDate_set: return "[set] .lastReviewDate" + case .p_user_get: return "[get] .user" + case .p_user_set: return "[set] .user" + case .p_userSettings_get: return "[get] .userSettings" + case .p_userSettings_set: return "[set] .userSettings" + case .p_resetAppSupportDirectoryUserData_get: return "[get] .resetAppSupportDirectoryUserData" + case .p_resetAppSupportDirectoryUserData_set: return "[set] .resetAppSupportDirectoryUserData" + case .p_useRelativeDates_get: return "[get] .useRelativeDates" + case .p_useRelativeDates_set: return "[set] .useRelativeDates" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func accessToken(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_accessToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func refreshToken(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_refreshToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func pushToken(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_pushToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appleSignFullName(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_appleSignFullName_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appleSignEmail(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_appleSignEmail_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func cookiesDate(getter defaultValue: Date?...) -> PropertyStub { + return Given(method: .p_cookiesDate_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func reviewLastShownVersion(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_reviewLastShownVersion_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func lastReviewDate(getter defaultValue: Date?...) -> PropertyStub { + return Given(method: .p_lastReviewDate_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func user(getter defaultValue: DataLayer.User?...) -> PropertyStub { + return Given(method: .p_user_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func userSettings(getter defaultValue: UserSettings?...) -> PropertyStub { + return Given(method: .p_userSettings_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func resetAppSupportDirectoryUserData(getter defaultValue: Bool?...) -> PropertyStub { + return Given(method: .p_resetAppSupportDirectoryUserData_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func useRelativeDates(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_useRelativeDates_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func clear() -> Verify { return Verify(method: .m_clear)} + public static var accessToken: Verify { return Verify(method: .p_accessToken_get) } + public static func accessToken(set newValue: Parameter) -> Verify { return Verify(method: .p_accessToken_set(newValue)) } + public static var refreshToken: Verify { return Verify(method: .p_refreshToken_get) } + public static func refreshToken(set newValue: Parameter) -> Verify { return Verify(method: .p_refreshToken_set(newValue)) } + public static var pushToken: Verify { return Verify(method: .p_pushToken_get) } + public static func pushToken(set newValue: Parameter) -> Verify { return Verify(method: .p_pushToken_set(newValue)) } + public static var appleSignFullName: Verify { return Verify(method: .p_appleSignFullName_get) } + public static func appleSignFullName(set newValue: Parameter) -> Verify { return Verify(method: .p_appleSignFullName_set(newValue)) } + public static var appleSignEmail: Verify { return Verify(method: .p_appleSignEmail_get) } + public static func appleSignEmail(set newValue: Parameter) -> Verify { return Verify(method: .p_appleSignEmail_set(newValue)) } + public static var cookiesDate: Verify { return Verify(method: .p_cookiesDate_get) } + public static func cookiesDate(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesDate_set(newValue)) } + public static var reviewLastShownVersion: Verify { return Verify(method: .p_reviewLastShownVersion_get) } + public static func reviewLastShownVersion(set newValue: Parameter) -> Verify { return Verify(method: .p_reviewLastShownVersion_set(newValue)) } + public static var lastReviewDate: Verify { return Verify(method: .p_lastReviewDate_get) } + public static func lastReviewDate(set newValue: Parameter) -> Verify { return Verify(method: .p_lastReviewDate_set(newValue)) } + public static var user: Verify { return Verify(method: .p_user_get) } + public static func user(set newValue: Parameter) -> Verify { return Verify(method: .p_user_set(newValue)) } + public static var userSettings: Verify { return Verify(method: .p_userSettings_get) } + public static func userSettings(set newValue: Parameter) -> Verify { return Verify(method: .p_userSettings_set(newValue)) } + public static var resetAppSupportDirectoryUserData: Verify { return Verify(method: .p_resetAppSupportDirectoryUserData_get) } + public static func resetAppSupportDirectoryUserData(set newValue: Parameter) -> Verify { return Verify(method: .p_resetAppSupportDirectoryUserData_set(newValue)) } + public static var useRelativeDates: Verify { return Verify(method: .p_useRelativeDates_get) } + public static func useRelativeDates(set newValue: Parameter) -> Verify { return Verify(method: .p_useRelativeDates_set(newValue)) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func clear(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_clear, 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 { @@ -4224,11 +4654,11 @@ open class ProfileRouterMock: ProfileRouter, Mock { } open func showSSOWebBrowser(title: String) { - addInvocation(.m_showWebBrowser__SSO(Parameter.value(`title`))) - let perform = methodPerformValue(.m_showWebBrowser__SSO(Parameter.value(`title`))) as? (String) -> Void - perform?(`title`) + addInvocation(.m_showSSOWebBrowser__title_title(Parameter.value(`title`))) + let perform = methodPerformValue(.m_showSSOWebBrowser__title_title(Parameter.value(`title`))) as? (String) -> Void + perform?(`title`) } - + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -4277,7 +4707,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case m_showForgotPasswordScreen case m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(Parameter, Parameter) case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) - case m_showWebBrowser__SSO(Parameter) + case m_showSSOWebBrowser__title_title(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_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) @@ -4371,6 +4801,11 @@ open class ProfileRouterMock: ProfileRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUrl, rhs: rhsUrl, with: matcher), lhsUrl, rhsUrl, "url")) return Matcher.ComparisonResult(results) + case (.m_showSSOWebBrowser__title_title(let lhsTitle), .m_showSSOWebBrowser__title_title(let rhsTitle)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTitle, rhs: rhsTitle, with: matcher), lhsTitle, rhsTitle, "title")) + return Matcher.ComparisonResult(results) + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let lhsAlerttitle, let lhsAlertmessage, let lhsPositiveaction, let lhsOnclosetapped, let lhsOktapped, let lhsType), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let rhsAlerttitle, let rhsAlertmessage, let rhsPositiveaction, let rhsOnclosetapped, let rhsOktapped, let rhsType)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) @@ -4434,7 +4869,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case .m_showForgotPasswordScreen: return 0 case let .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(p0, p1): return p0.intValue + p1.intValue case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue - case let .m_showWebBrowser__SSO(p0): return p0.intValue + case let .m_showSSOWebBrowser__title_title(p0): return p0.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_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue @@ -4465,7 +4900,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen: return ".showDiscoveryScreen(searchQuery:sourceScreen:)" case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" - case .m_showWebBrowser__SSO: return ".showSSOWebBrowser(title:)" + case .m_showSSOWebBrowser__title_title: return ".showSSOWebBrowser(title:)" 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_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" @@ -4510,6 +4945,7 @@ open class ProfileRouterMock: ProfileRouter, Mock { public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} public static func showDiscoveryScreen(searchQuery: Parameter, sourceScreen: Parameter) -> Verify { return Verify(method: .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(`searchQuery`, `sourceScreen`))} public static func showWebBrowser(title: Parameter, url: Parameter) -> Verify { return Verify(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`))} + public static func showSSOWebBrowser(title: Parameter) -> Verify { return Verify(method: .m_showSSOWebBrowser__title_title(`title`))} 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, completion: Parameter<(() -> Void)?>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`))} @@ -4586,6 +5022,9 @@ open class ProfileRouterMock: ProfileRouter, Mock { public static func showWebBrowser(title: Parameter, url: Parameter, perform: @escaping (String, URL) -> Void) -> Perform { return Perform(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`), performs: perform) } + public static func showSSOWebBrowser(title: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_showSSOWebBrowser__title_title(`title`), performs: perform) + } public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } diff --git a/generateAllMocks.sh b/generateAllMocks.sh index 0c4b5cfc7..6f30225ca 100755 --- a/generateAllMocks.sh +++ b/generateAllMocks.sh @@ -1,6 +1,8 @@ #!/bin/bash DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) cd "${DIR}" +cd ./Core +./../Pods/SwiftyMocky/bin/swiftymocky generate cd ./Authorization ./../Pods/SwiftyMocky/bin/swiftymocky generate cd ../Course @@ -14,4 +16,4 @@ cd ../Discussion cd ../Profile ./../Pods/SwiftyMocky/bin/swiftymocky generate cd ../WhatsNew -./../Pods/SwiftyMocky/bin/swiftymocky generate \ No newline at end of file +./../Pods/SwiftyMocky/bin/swiftymocky generate From e76053c837d37f07088da429332f8ecdd259459c Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Wed, 30 Oct 2024 14:04:08 +0100 Subject: [PATCH 49/55] fix: Part 3 sync to upstream (#533) * chore: show unsupported error screen for gated content (#37) * chore: string auto-generating * chore: removed unused func * chore: update facebook sdk to solve open(url) issue --------- Co-authored-by: Saeed Bashir Co-authored-by: Anton Yarmolenko <37253+rnr@users.noreply.github.com> --- Core/Core.xcodeproj/project.pbxproj | 2 +- Core/Core/Domain/Model/CourseBlockModel.swift | 5 +- Course/Course.xcodeproj/project.pbxproj | 8 +- Course/Course/Data/CourseRepository.swift | 6 +- Course/Course/Domain/CourseInteractor.swift | 3 +- Course/Course/Presentation/CourseRouter.swift | 4 + .../Outline/ContinueWithView.swift | 3 +- .../CourseVertical/CourseVerticalView.swift | 6 +- .../Subviews/CustomDisclosureGroup.swift | 20 +++-- .../Presentation/Unit/CourseUnitView.swift | 10 +-- .../DropdownList/CourseUnitDropDownCell.swift | 3 +- .../DropdownList/CourseUnitDropDownList.swift | 12 ++- .../CourseUnitVerticalsDropdownView.swift | 12 ++- .../Subviews/NotAvailableOnMobileView.swift | 45 +++++++++++ .../Unit/Subviews/UnknownView.swift | 41 ---------- .../CourseContainerViewModelTests.swift | 24 ++++-- .../Unit/CourseUnitViewModelTests.swift | 76 ++++++++++--------- OpenEdX/Router.swift | 7 ++ 18 files changed, 172 insertions(+), 115 deletions(-) create mode 100644 Course/Course/Presentation/Unit/Subviews/NotAvailableOnMobileView.swift delete mode 100644 Course/Course/Presentation/Unit/Subviews/UnknownView.swift diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 3ba4ab39b..081a4c261 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -2466,7 +2466,7 @@ repositoryURL = "https://github.com/facebook/facebook-ios-sdk"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 16.3.1; + minimumVersion = 17.4.0; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Core/Core/Domain/Model/CourseBlockModel.swift b/Core/Core/Domain/Model/CourseBlockModel.swift index c2525627b..731e47176 100644 --- a/Core/Core/Domain/Model/CourseBlockModel.swift +++ b/Core/Core/Domain/Model/CourseBlockModel.swift @@ -166,6 +166,7 @@ public struct CourseVertical: Identifiable, Hashable { public let type: BlockType public let completion: Double public var childs: [CourseBlock] + public var webUrl: String public var isDownloadable: Bool { return childs.first(where: { $0.isDownloadable }) != nil @@ -178,7 +179,8 @@ public struct CourseVertical: Identifiable, Hashable { displayName: String, type: BlockType, completion: Double, - childs: [CourseBlock] + childs: [CourseBlock], + webUrl: String ) { self.blockId = blockId self.id = id @@ -187,6 +189,7 @@ public struct CourseVertical: Identifiable, Hashable { self.type = type self.completion = completion self.childs = childs + self.webUrl = webUrl } } diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index 737558bc2..81830204f 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -27,7 +27,7 @@ 02454CA02A2618E70043052A /* YouTubeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02454C9F2A2618E70043052A /* YouTubeView.swift */; }; 02454CA22A26190A0043052A /* EncodedVideoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02454CA12A26190A0043052A /* EncodedVideoView.swift */; }; 02454CA42A26193F0043052A /* WebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02454CA32A26193F0043052A /* WebView.swift */; }; - 02454CA62A26196C0043052A /* UnknownView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02454CA52A26196C0043052A /* UnknownView.swift */; }; + 02454CA62A26196C0043052A /* NotAvailableOnMobileView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02454CA52A26196C0043052A /* NotAvailableOnMobileView.swift */; }; 02454CA82A2619890043052A /* DiscussionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02454CA72A2619890043052A /* DiscussionView.swift */; }; 02454CAA2A2619B40043052A /* LessonProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02454CA92A2619B40043052A /* LessonProgressView.swift */; }; 0248C92729C097EB00DC8402 /* CourseVerticalViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0248C92629C097EB00DC8402 /* CourseVerticalViewModel.swift */; }; @@ -134,7 +134,7 @@ 02454C9F2A2618E70043052A /* YouTubeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = YouTubeView.swift; sourceTree = ""; }; 02454CA12A26190A0043052A /* EncodedVideoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EncodedVideoView.swift; sourceTree = ""; }; 02454CA32A26193F0043052A /* WebView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebView.swift; sourceTree = ""; }; - 02454CA52A26196C0043052A /* UnknownView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnknownView.swift; sourceTree = ""; }; + 02454CA52A26196C0043052A /* NotAvailableOnMobileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotAvailableOnMobileView.swift; sourceTree = ""; }; 02454CA72A2619890043052A /* DiscussionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscussionView.swift; sourceTree = ""; }; 02454CA92A2619B40043052A /* LessonProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LessonProgressView.swift; sourceTree = ""; }; 0248C92629C097EB00DC8402 /* CourseVerticalViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseVerticalViewModel.swift; sourceTree = ""; }; @@ -285,7 +285,7 @@ 02454C9F2A2618E70043052A /* YouTubeView.swift */, 02454CA12A26190A0043052A /* EncodedVideoView.swift */, 02454CA32A26193F0043052A /* WebView.swift */, - 02454CA52A26196C0043052A /* UnknownView.swift */, + 02454CA52A26196C0043052A /* NotAvailableOnMobileView.swift */, 02454CA72A2619890043052A /* DiscussionView.swift */, 02454CA92A2619B40043052A /* LessonProgressView.swift */, BAD9CA2C2B2736BB00DE790A /* LessonLineProgressView.swift */, @@ -951,7 +951,7 @@ BA58CF5D2B3D804D005B102E /* CourseStorage.swift in Sources */, 067B7B502BED339200D1768F /* PlayerControllerProtocol.swift in Sources */, 067B7B4E2BED339200D1768F /* PlayerTrackerProtocol.swift in Sources */, - 02454CA62A26196C0043052A /* UnknownView.swift in Sources */, + 02454CA62A26196C0043052A /* NotAvailableOnMobileView.swift in Sources */, 0766DFD0299AB29000EBEF6A /* PlayerViewController.swift in Sources */, 022C64DC29ACFDEE000F532B /* Data_HandoutsResponse.swift in Sources */, 022C64D829ACEC48000F532B /* HandoutsView.swift in Sources */, diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index 5da361928..09e86c588 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -202,7 +202,8 @@ public class CourseRepository: CourseRepositoryProtocol { displayName: sequential.displayName, type: BlockType(rawValue: sequential.type) ?? .unknown, completion: sequential.completion ?? 0, - childs: childs + childs: childs, + webUrl: sequential.webUrl ) } @@ -440,7 +441,8 @@ And there are various ways of describing it-- call it oral poetry or displayName: sequential.displayName, type: BlockType(rawValue: sequential.type) ?? .unknown, completion: sequential.completion ?? 0, - childs: childs + childs: childs, + webUrl: sequential.webUrl ) } diff --git a/Course/Course/Domain/CourseInteractor.swift b/Course/Course/Domain/CourseInteractor.swift index 42f6074f4..bcafab40d 100644 --- a/Course/Course/Domain/CourseInteractor.swift +++ b/Course/Course/Domain/CourseInteractor.swift @@ -170,7 +170,8 @@ public class CourseInteractor: CourseInteractorProtocol { displayName: vertical.displayName, type: vertical.type, completion: vertical.completion, - childs: newChilds + childs: newChilds, + webUrl: vertical.webUrl ) } diff --git a/Course/Course/Presentation/CourseRouter.swift b/Course/Course/Presentation/CourseRouter.swift index a9b33ef79..f3efe9cae 100644 --- a/Course/Course/Presentation/CourseRouter.swift +++ b/Course/Course/Presentation/CourseRouter.swift @@ -62,6 +62,8 @@ public protocol CourseRouter: BaseRouter { ) func showDatesAndCalendar() + + func showGatedContentError(url: String) } // Mark - For testing and SwiftUI preview @@ -122,5 +124,7 @@ public class CourseRouterMock: BaseRouterMock, CourseRouter { ) {} public func showDatesAndCalendar() {} + + public func showGatedContentError(url: String) {} } #endif diff --git a/Course/Course/Presentation/Outline/ContinueWithView.swift b/Course/Course/Presentation/Outline/ContinueWithView.swift index 0380e51d7..e8345bbb0 100644 --- a/Course/Course/Presentation/Outline/ContinueWithView.swift +++ b/Course/Course/Presentation/Outline/ContinueWithView.swift @@ -125,7 +125,8 @@ struct ContinueWithView_Previews: PreviewProvider { displayName: "Second Unit", type: .vertical, completion: 1, - childs: blocks + childs: blocks, + webUrl: "" ) ) { } } diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift index 1614e075d..ff3306072 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift @@ -161,8 +161,10 @@ struct CourseVerticalView_Previews: PreviewProvider { displayName: "Vertical", type: .vertical, completion: 0, - childs: []) - ], + childs: [], + webUrl: "" + ) + ], sequentialProgress: SequentialProgress( assignmentType: "Advanced Assessment Tools", numPointsEarned: 1, diff --git a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift index e783bdc07..f7ae7ea54 100644 --- a/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift +++ b/Course/Course/Presentation/Subviews/CustomDisclosureGroup.swift @@ -80,7 +80,10 @@ struct CustomDisclosureGroup: View { guard let chapterIndex = chapterIndex else { return } guard let sequentialIndex else { return } guard let courseVertical = sequential.childs.first else { return } - guard let block = courseVertical.childs.first else { return } + guard let block = courseVertical.childs.first else { + viewModel.router.showGatedContentError(url: courseVertical.webUrl) + return + } viewModel.trackSequentialClicked(sequential) if viewModel.config.uiComponents.courseDropDownNavigationEnabled { @@ -279,7 +282,8 @@ struct CustomDisclosureGroup_Previews: PreviewProvider { displayName: "Vertical 1", type: .vertical, completion: 0, - childs: [] + childs: [], + webUrl: "" ), CourseVertical( blockId: "1-1-2", @@ -288,7 +292,8 @@ struct CustomDisclosureGroup_Previews: PreviewProvider { displayName: "Vertical 2", type: .vertical, completion: 1.0, - childs: [] + childs: [], + webUrl: "" ) ], sequentialProgress: SequentialProgress( @@ -312,7 +317,8 @@ struct CustomDisclosureGroup_Previews: PreviewProvider { displayName: "Vertical 3", type: .vertical, completion: 1.0, - childs: [] + childs: [], + webUrl: "" ) ], sequentialProgress: SequentialProgress( @@ -344,7 +350,8 @@ struct CustomDisclosureGroup_Previews: PreviewProvider { displayName: "Vertical 4", type: .vertical, completion: 1.0, - childs: [] + childs: [], + webUrl: "" ), CourseVertical( blockId: "2-1-2", @@ -353,7 +360,8 @@ struct CustomDisclosureGroup_Previews: PreviewProvider { displayName: "Vertical 5", type: .vertical, completion: 1.0, - childs: [] + childs: [], + webUrl: "" ) ], sequentialProgress: SequentialProgress( diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index f2a50efe1..81736c6fe 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -256,10 +256,8 @@ public struct CourseUnitView: View { case .unknown(let url): if index >= viewModel.index - 1 && index <= viewModel.index + 1 { if viewModel.connectivity.isInternetAvaliable { - UnknownView(url: url, viewModel: viewModel) + NotAvailableOnMobileView(url: url) .frameLimit(width: reader.size.width) - Spacer() - .frame(minHeight: 100) } else { OfflineContentView( isDownloadable: false @@ -539,7 +537,8 @@ struct CourseUnitView_Previews: PreviewProvider { displayName: "6", type: .vertical, completion: 0, - childs: blocks + childs: blocks, + webUrl: "" ) ], sequentialProgress: SequentialProgress( @@ -572,7 +571,8 @@ struct CourseUnitView_Previews: PreviewProvider { displayName: "4", type: .vertical, completion: 0, - childs: blocks + childs: blocks, + webUrl: "" ) ], sequentialProgress: SequentialProgress( diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift index a00a2de0d..5a280cbeb 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownCell.swift @@ -87,7 +87,8 @@ struct CourseUnitDropDownCell_Previews: PreviewProvider { multiDevice: true, offlineDownload: nil ) - ] + ], + webUrl: "" ) CourseUnitDropDownCell( diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift index 86459a13a..e449f77fc 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitDropDownList.swift @@ -119,7 +119,8 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { displayName: "First Unit", type: .vertical, completion: 0, - childs: blocks + childs: blocks, + webUrl: "" ), CourseVertical( blockId: "2", @@ -128,7 +129,8 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { displayName: "Second Unit", type: .vertical, completion: 1, - childs: blocks + childs: blocks, + webUrl: "" ), CourseVertical( blockId: "3", @@ -137,7 +139,8 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { displayName: "Third Unit", type: .vertical, completion: 0, - childs: blocks + childs: blocks, + webUrl: "" ), CourseVertical( blockId: "4", @@ -146,7 +149,8 @@ struct CourseUnitDropDownList_Previews: PreviewProvider { displayName: "Fourth Unit", type: .vertical, completion: 1, - childs: blocks + childs: blocks, + webUrl: "" ) ] diff --git a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift index e5f289ea4..89f410ed3 100644 --- a/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift +++ b/Course/Course/Presentation/Unit/Subviews/DropdownList/CourseUnitVerticalsDropdownView.swift @@ -135,7 +135,8 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { displayName: "First Unit", type: .vertical, completion: 0, - childs: blocks + childs: blocks, + webUrl: "" ), CourseVertical( blockId: "2", @@ -144,7 +145,8 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { displayName: "Second Unit", type: .vertical, completion: 1, - childs: blocks + childs: blocks, + webUrl: "" ), CourseVertical( blockId: "3", @@ -153,7 +155,8 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { displayName: "Third Unit", type: .vertical, completion: 0, - childs: blocks + childs: blocks, + webUrl: "" ), CourseVertical( blockId: "4", @@ -162,7 +165,8 @@ struct CourseUnitVerticalsDropdownView_Previews: PreviewProvider { displayName: "Fourth Unit", type: .vertical, completion: 1, - childs: blocks + childs: blocks, + webUrl: "" ) ] diff --git a/Course/Course/Presentation/Unit/Subviews/NotAvailableOnMobileView.swift b/Course/Course/Presentation/Unit/Subviews/NotAvailableOnMobileView.swift new file mode 100644 index 000000000..49741df69 --- /dev/null +++ b/Course/Course/Presentation/Unit/Subviews/NotAvailableOnMobileView.swift @@ -0,0 +1,45 @@ +// +// NotAvailableOnMobileView.swift +// Course +// +// Created by  Stepanok Ivan on 30.05.2023. +// + +import SwiftUI +import Core +import Theme + +public struct NotAvailableOnMobileView: View { + let url: String + + public init(url: String) { + self.url = url + } + + public var body: some View { + ZStack(alignment: .center) { + VStack(spacing: 10) { + Spacer() + CoreAssets.notAvaliable.swiftUIImage + Text(CourseLocalization.NotAvaliable.title) + .font(Theme.Fonts.titleLarge) + .multilineTextAlignment(.center) + .padding(.top, 40) + Text(CourseLocalization.NotAvaliable.description) + .font(Theme.Fonts.bodyLarge) + .multilineTextAlignment(.center) + .padding(.top, 12) + StyledButton(CourseLocalization.NotAvaliable.button, action: { + if let url = URL(string: url), UIApplication.shared.canOpenURL(url) { + UIApplication.shared.open(url) + } + }) + .frame(width: 215) + .padding(.top, 40) + Spacer() + } + .padding(24) + } + .navigationBarHidden(false) + } +} diff --git a/Course/Course/Presentation/Unit/Subviews/UnknownView.swift b/Course/Course/Presentation/Unit/Subviews/UnknownView.swift deleted file mode 100644 index d38104a23..000000000 --- a/Course/Course/Presentation/Unit/Subviews/UnknownView.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// UnknownView.swift -// Course -// -// Created by  Stepanok Ivan on 30.05.2023. -// - -import SwiftUI -import Core -import Theme - -struct UnknownView: View { - let url: String - let viewModel: CourseUnitViewModel - - var body: some View { - VStack(spacing: 0) { - Spacer() - CoreAssets.notAvaliable.swiftUIImage - Text(CourseLocalization.NotAvaliable.title) - .font(Theme.Fonts.titleLarge) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .padding(.top, 40) - Text(CourseLocalization.NotAvaliable.description) - .font(Theme.Fonts.bodyLarge) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - .padding(.top, 12) - StyledButton(CourseLocalization.NotAvaliable.button, action: { - if let url = URL(string: url) { - UIApplication.shared.open(url) - } - }) - .frame(width: 215) - .padding(.top, 40) - Spacer() - } - .padding(24) - } -} diff --git a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift index 09895748c..c87824041 100644 --- a/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift +++ b/Course/CourseTests/Presentation/Container/CourseContainerViewModelTests.swift @@ -67,7 +67,8 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .vertical, completion: 0, - childs: [block] + childs: [block], + webUrl: "" ) let sequential = CourseSequential( blockId: "", @@ -397,7 +398,8 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .vertical, completion: 0, - childs: [block] + childs: [block], + webUrl: "" ) let sequential = CourseSequential( @@ -543,7 +545,8 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .vertical, completion: 0, - childs: [block] + childs: [block], + webUrl: "" ) let sequential = CourseSequential( @@ -672,7 +675,8 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .vertical, completion: 0, - childs: [block] + childs: [block], + webUrl: "" ) let sequential = CourseSequential( @@ -802,7 +806,8 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .vertical, completion: 0, - childs: [block] + childs: [block], + webUrl: "" ) let sequential = CourseSequential( @@ -925,7 +930,8 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .vertical, completion: 0, - childs: [block] + childs: [block], + webUrl: "" ) let sequential = CourseSequential( @@ -1064,7 +1070,8 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .vertical, completion: 0, - childs: [block] + childs: [block], + webUrl: "" ) let sequential = CourseSequential( @@ -1225,7 +1232,8 @@ final class CourseContainerViewModelTests: XCTestCase { displayName: "", type: .vertical, completion: 0, - childs: [block, block2] + childs: [block, block2], + webUrl: "" ) let sequential = CourseSequential( diff --git a/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift b/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift index dd24288b6..8a43f206e 100644 --- a/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift +++ b/Course/CourseTests/Presentation/Unit/CourseUnitViewModelTests.swift @@ -85,23 +85,27 @@ final class CourseUnitViewModelTests: XCTestCase { displayName: "0", type: .chapter, childs: [ - CourseSequential(blockId: "5", - id: "5", - displayName: "5", - type: .sequential, - completion: 0, - childs: [ - CourseVertical(blockId: "6", - id: "6", - courseId: "123", - displayName: "6", - type: .vertical, - completion: 0, - childs: blocks) - ], - sequentialProgress: nil, - due: Date() - ) + CourseSequential( + blockId: "5", + id: "5", + displayName: "5", + type: .sequential, + completion: 0, + childs: [ + CourseVertical( + blockId: "6", + id: "6", + courseId: "123", + displayName: "6", + type: .vertical, + completion: 0, + childs: blocks, + webUrl: "" + ) + ], + sequentialProgress: nil, + due: Date() + ) ]), CourseChapter( @@ -110,23 +114,27 @@ final class CourseUnitViewModelTests: XCTestCase { displayName: "2", type: .chapter, childs: [ - CourseSequential(blockId: "3", - id: "3", - displayName: "3", - type: .sequential, - completion: 0, - childs: [ - CourseVertical(blockId: "4", - id: "4", - courseId: "123", - displayName: "4", - type: .vertical, - completion: 0, - childs: blocks) - ], - sequentialProgress: nil, - due: Date() - ) + CourseSequential( + blockId: "3", + id: "3", + displayName: "3", + type: .sequential, + completion: 0, + childs: [ + CourseVertical( + blockId: "4", + id: "4", + courseId: "123", + displayName: "4", + type: .vertical, + completion: 0, + childs: blocks, + webUrl: "" + ) + ], + sequentialProgress: nil, + due: Date() + ) ]) ] diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 0004ca0cf..4302385d6 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -544,6 +544,13 @@ public class Router: AuthorizationRouter, } } + public func showGatedContentError(url: String) { + let view = NotAvailableOnMobileView(url: url) + + let controller = UIHostingController(rootView: view) + navigationController.pushViewController(controller, animated: true) + } + private func openBlockInBrowser(blockURL: URL) { presentAlert( alertTitle: "", From af2ba211cd51eafc1db0404f4ada6f9a5939a725 Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta Date: Wed, 30 Oct 2024 18:43:29 +0200 Subject: [PATCH 50/55] build: upgrade fastlane version (#534) --- .github/workflows/swiftlint.yml | 37 ++++++++++++++++++++++++++++++++ .github/workflows/unit_tests.yml | 7 ------ Gemfile.lock | 32 +++++++++++++++------------ fastlane/Fastfile | 4 ++-- 4 files changed, 57 insertions(+), 23 deletions(-) create mode 100644 .github/workflows/swiftlint.yml diff --git a/.github/workflows/swiftlint.yml b/.github/workflows/swiftlint.yml new file mode 100644 index 000000000..d2d579ce3 --- /dev/null +++ b/.github/workflows/swiftlint.yml @@ -0,0 +1,37 @@ +name: SwiftLint + +on: + workflow_dispatch: + + pull_request: + +jobs: + lint: + name: Lint + runs-on: macos-latest + + concurrency: + # When running on develop, use the sha to allow all runs of this workflow to run concurrently. + # Otherwise only allow a single run of this workflow on each branch, automatically cancelling older runs. + group: ${{ github.ref == 'refs/heads/develop' && format('swiftlint-develop-{0}', github.sha) || format('swiftlint-{0}', github.ref) }} + cancel-in-progress: true + + steps: + - uses: nschloe/action-cached-lfs-checkout@v1.2.1 + with: + ref: ${{ github.event.pull_request.head.sha }} + + - uses: actions/cache@v3 + with: + path: vendor/bundle + key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }} + restore-keys: | + ${{ runner.os }}-gems- + + - name: Setup environment + run: + source ci_scripts/ci_prepare_env.sh && setup_github_actions_environment + + - name: SwiftLint + run: + bundle exec fastlane linting diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index ee3806b98..99a84f445 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -3,9 +3,6 @@ name: Unit Tests on: workflow_dispatch: - push: - branches: [ develop ] - pull_request: jobs: @@ -35,10 +32,6 @@ jobs: run: source ci_scripts/ci_prepare_env.sh && setup_github_actions_environment - - name: SwiftLint - run: - bundle exec fastlane linting - - name: Run tests run: bundle exec fastlane unit_tests diff --git a/Gemfile.lock b/Gemfile.lock index f6449ff37..d9bc70b14 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -10,20 +10,20 @@ GEM artifactory (3.0.17) atomos (0.1.3) aws-eventstream (1.3.0) - aws-partitions (1.987.0) - aws-sdk-core (3.209.1) + aws-partitions (1.998.0) + aws-sdk-core (3.211.0) aws-eventstream (~> 1, >= 1.3.0) - aws-partitions (~> 1, >= 1.651.0) + aws-partitions (~> 1, >= 1.992.0) aws-sigv4 (~> 1.9) jmespath (~> 1, >= 1.6.1) - aws-sdk-kms (1.94.0) - aws-sdk-core (~> 3, >= 3.207.0) + aws-sdk-kms (1.95.0) + aws-sdk-core (~> 3, >= 3.210.0) aws-sigv4 (~> 1.5) - aws-sdk-s3 (1.167.0) - aws-sdk-core (~> 3, >= 3.207.0) + aws-sdk-s3 (1.169.0) + aws-sdk-core (~> 3, >= 3.210.0) aws-sdk-kms (~> 1) aws-sigv4 (~> 1.5) - aws-sigv4 (1.10.0) + aws-sigv4 (1.10.1) aws-eventstream (~> 1, >= 1.0.2) babosa (1.0.4) base64 (0.2.0) @@ -68,7 +68,7 @@ GEM faraday_middleware (1.2.1) faraday (~> 1.0) fastimage (2.3.1) - fastlane (2.224.0) + fastlane (2.225.0) CFPropertyList (>= 2.3, < 4.0.0) addressable (>= 2.8, < 3.0.0) artifactory (~> 3.0) @@ -84,6 +84,7 @@ GEM faraday-cookie_jar (~> 0.0.6) faraday_middleware (~> 1.0) fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.0.0) gh_inspector (>= 1.1.2, < 2.0.0) google-apis-androidpublisher_v3 (~> 0.3) google-apis-playcustomapp_v1 (~> 0.1) @@ -109,6 +110,8 @@ GEM xcodeproj (>= 1.13.0, < 2.0.0) xcpretty (~> 0.3.0) xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) gh_inspector (1.1.3) google-apis-androidpublisher_v3 (0.54.0) google-apis-core (>= 0.11.0, < 2.a) @@ -151,14 +154,14 @@ GEM domain_name (~> 0.5) httpclient (2.8.3) jmespath (1.6.2) - json (2.7.2) + json (2.7.5) jwt (2.9.3) base64 mini_magick (4.13.2) mini_mime (1.1.5) multi_json (1.15.0) multipart-post (2.4.1) - nanaimo (0.3.0) + nanaimo (0.4.0) naturally (2.2.1) nkf (0.2.0) optparse (0.5.0) @@ -171,7 +174,7 @@ GEM trailblazer-option (>= 0.1.1, < 0.2.0) uber (< 0.2.0) retriable (3.1.2) - rexml (3.3.8) + rexml (3.3.9) rouge (2.0.7) ruby2_keywords (0.0.5) rubyzip (2.3.2) @@ -184,6 +187,7 @@ GEM simctl (1.6.10) CFPropertyList naturally + sysrandom (1.0.5) terminal-notifier (2.0.0) terminal-table (3.0.2) unicode-display_width (>= 1.1.1, < 3) @@ -198,12 +202,12 @@ GEM xcode-install (2.8.1) claide (>= 0.9.1) fastlane (>= 2.1.0, < 3.0.0) - xcodeproj (1.25.1) + xcodeproj (1.26.0) CFPropertyList (>= 2.3.3, < 4.0) atomos (~> 0.1.3) claide (>= 1.0.2, < 2.0) colored2 (~> 3.1) - nanaimo (~> 0.3.0) + nanaimo (~> 0.4.0) rexml (>= 3.3.6, < 4.0) xcpretty (0.3.0) rouge (~> 2.0.7) diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 137d92894..5393609bf 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -15,7 +15,7 @@ update_fastlane before_all do xcodes( - version: '16.0', + version: '16.1', select_for_current_build_only: true, ) @@ -37,7 +37,7 @@ end lane :unit_tests do run_tests( workspace: "OpenEdX.xcworkspace", - device: "iPhone 15", + device: "iPhone 16", scheme: "OpenEdXDev" ) end From f7eeae8bb9f0fb22495a17704439e872349b85a9 Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Fri, 1 Nov 2024 17:09:04 +0200 Subject: [PATCH 51/55] test: AllCoursesViewModel, PrimaryCoursesViewModel, DatesAndCalendarViewModel, CalendarManager (#535) --- .../AuthorizationMock.generated.swift | 509 +- .../Login/SignInViewModelTests.swift | 4 +- Core/Core/Configuration/Config/Config.swift | 1 + Core/Core/Domain/Model/CourseForSync.swift | 8 + .../View/Base/CalendarManagerProtocol.swift | 1 + Core/CoreTests/CoreMock.generated.swift | 893 +++ .../DownloadManagerTests.swift | 3 + Course/CourseTests/CourseMock.generated.swift | 893 +++ Dashboard/Dashboard.xcodeproj/project.pbxproj | 8 + .../DashboardMock.generated.swift | 893 +++ .../AllCoursesViewModelTests.swift | 219 + ...PrimaryCourseDashboardViewModelTests.swift | 213 + .../DiscoveryMock.generated.swift | 893 +++ .../DiscussionMock.generated.swift | 893 +++ Profile/Profile.xcodeproj/project.pbxproj | 8 + .../ProfilePersistenceProtocol.swift | 1 + Profile/Profile/Data/ProfileStorage.swift | 1 + .../DatesAndCalendarViewModel.swift | 4 +- .../ProfileTests/CalendarManagerTests.swift | 290 + .../DatesAndCalendarViewModelTests.swift | 328 + .../ProfileTests/ProfileMock.generated.swift | 5949 +++++++++++------ 21 files changed, 9765 insertions(+), 2247 deletions(-) create mode 100644 Dashboard/DashboardTests/Presentation/AllCoursesViewModelTests.swift create mode 100644 Dashboard/DashboardTests/Presentation/PrimaryCourseDashboardViewModelTests.swift create mode 100644 Profile/ProfileTests/CalendarManagerTests.swift create mode 100644 Profile/ProfileTests/DatesAndCalendarViewModelTests.swift diff --git a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift index b6106d4d0..59adf71ce 100644 --- a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift +++ b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift @@ -93,23 +93,22 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { return __value } - @discardableResult - open func login(ssoToken: String) async throws -> Core.User { - addInvocation(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) - let perform = methodPerformValue(.m_login__SSO__username_password(Parameter.value(`ssoToken`))) as? (String) -> Void - perform?(`ssoToken`) - var __value: User - do { - __value = try methodReturnValue(.m_login__SSO__username_password(Parameter.value(ssoToken))).casted() - } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for login(username: String, password: String). Use given") - Failure("Stub return value not specified for login(username: String, password: String). Use given") - } catch { - throw error - } - return __value + open func login(ssoToken: String) throws -> User { + addInvocation(.m_login__ssoToken_ssoToken(Parameter.value(`ssoToken`))) + let perform = methodPerformValue(.m_login__ssoToken_ssoToken(Parameter.value(`ssoToken`))) as? (String) -> Void + perform?(`ssoToken`) + var __value: User + do { + __value = try methodReturnValue(.m_login__ssoToken_ssoToken(Parameter.value(`ssoToken`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for login(ssoToken: String). Use given") + Failure("Stub return value not specified for login(ssoToken: String). Use given") + } catch { + throw error + } + return __value } - + open func resetPassword(email: String) throws -> ResetPassword { addInvocation(.m_resetPassword__email_email(Parameter.value(`email`))) let perform = methodPerformValue(.m_resetPassword__email_email(Parameter.value(`email`))) as? (String) -> Void @@ -190,8 +189,8 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { fileprivate enum MethodType { case m_login__username_usernamepassword_password(Parameter, Parameter) - case m_login__SSO__username_password(Parameter) case m_login__externalToken_externalTokenbackend_backend(Parameter, Parameter) + case m_login__ssoToken_ssoToken(Parameter) case m_resetPassword__email_email(Parameter) case m_getCookies__force_force(Parameter) case m_getRegistrationFields @@ -206,17 +205,17 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsPassword, rhs: rhsPassword, with: matcher), lhsPassword, rhsPassword, "password")) return Matcher.ComparisonResult(results) - case (.m_login__SSO__username_password(let lhsJwtToken), .m_login__SSO__username_password(let rhsJwtToken)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsJwtToken, rhs: rhsJwtToken, with: matcher), lhsJwtToken, rhsJwtToken, "jwtToken")) - return Matcher.ComparisonResult(results) - case (.m_login__externalToken_externalTokenbackend_backend(let lhsExternaltoken, let lhsBackend), .m_login__externalToken_externalTokenbackend_backend(let rhsExternaltoken, let rhsBackend)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsExternaltoken, rhs: rhsExternaltoken, with: matcher), lhsExternaltoken, rhsExternaltoken, "externalToken")) results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBackend, rhs: rhsBackend, with: matcher), lhsBackend, rhsBackend, "backend")) return Matcher.ComparisonResult(results) + case (.m_login__ssoToken_ssoToken(let lhsSsotoken), .m_login__ssoToken_ssoToken(let rhsSsotoken)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSsotoken, rhs: rhsSsotoken, with: matcher), lhsSsotoken, rhsSsotoken, "ssoToken")) + return Matcher.ComparisonResult(results) + case (.m_resetPassword__email_email(let lhsEmail), .m_resetPassword__email_email(let rhsEmail)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEmail, rhs: rhsEmail, with: matcher), lhsEmail, rhsEmail, "email")) @@ -246,8 +245,8 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func intValue() -> Int { switch self { case let .m_login__username_usernamepassword_password(p0, p1): return p0.intValue + p1.intValue - case let .m_login__SSO__username_password(p0): return p0.intValue case let .m_login__externalToken_externalTokenbackend_backend(p0, p1): return p0.intValue + p1.intValue + case let .m_login__ssoToken_ssoToken(p0): return p0.intValue case let .m_resetPassword__email_email(p0): return p0.intValue case let .m_getCookies__force_force(p0): return p0.intValue case .m_getRegistrationFields: return 0 @@ -258,8 +257,8 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { func assertionName() -> String { switch self { case .m_login__username_usernamepassword_password: return ".login(username:password:)" - case .m_login__SSO__username_password: return ".loginSSO(username:password:)" case .m_login__externalToken_externalTokenbackend_backend: return ".login(externalToken:backend:)" + case .m_login__ssoToken_ssoToken: return ".login(ssoToken:)" case .m_resetPassword__email_email: return ".resetPassword(email:)" case .m_getCookies__force_force: return ".getCookies(force:)" case .m_getRegistrationFields: return ".getRegistrationFields()" @@ -286,9 +285,8 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func login(externalToken: Parameter, backend: Parameter, willReturn: User...) -> MethodStub { return Given(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`), products: willReturn.map({ StubProduct.return($0 as Any) })) } - @discardableResult - public static func ssoLogin(title: Parameter, willReturn: User...) -> MethodStub { - return Given(method: .m_login__SSO__username_password(`title`), products: willReturn.map({ StubProduct.return($0 as Any) })) + public static func login(ssoToken: Parameter, willReturn: User...) -> MethodStub { + return Given(method: .m_login__ssoToken_ssoToken(`ssoToken`), products: willReturn.map({ StubProduct.return($0 as Any) })) } public static func resetPassword(email: Parameter, willReturn: ResetPassword...) -> MethodStub { return Given(method: .m_resetPassword__email_email(`email`), products: willReturn.map({ StubProduct.return($0 as Any) })) @@ -326,6 +324,16 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { willProduce(stubber) return given } + public static func login(ssoToken: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_login__ssoToken_ssoToken(`ssoToken`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func login(ssoToken: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_login__ssoToken_ssoToken(`ssoToken`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (User).self) + willProduce(stubber) + return given + } public static func resetPassword(email: Parameter, willThrow: Error...) -> MethodStub { return Given(method: .m_resetPassword__email_email(`email`), products: willThrow.map({ StubProduct.throw($0) })) } @@ -382,10 +390,10 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { fileprivate var method: MethodType @discardableResult - public static func login(username: Parameter, password: Parameter) -> Verify { return Verify(method: .m_login__username_usernamepassword_password(`username`, `password`))} - public static func ssoLogin(title: Parameter) -> Verify { return Verify(method: .m_login__SSO__username_password(`title`))} + public static func login(username: Parameter, password: Parameter) -> Verify { return Verify(method: .m_login__username_usernamepassword_password(`username`, `password`))} @discardableResult public static func login(externalToken: Parameter, backend: Parameter) -> Verify { return Verify(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`))} + public static func login(ssoToken: Parameter) -> Verify { return Verify(method: .m_login__ssoToken_ssoToken(`ssoToken`))} public static func resetPassword(email: Parameter) -> Verify { return Verify(method: .m_resetPassword__email_email(`email`))} public static func getCookies(force: Parameter) -> Verify { return Verify(method: .m_getCookies__force_force(`force`))} public static func getRegistrationFields() -> Verify { return Verify(method: .m_getRegistrationFields)} @@ -405,6 +413,9 @@ open class AuthInteractorProtocolMock: AuthInteractorProtocol, Mock { public static func login(externalToken: Parameter, backend: Parameter, perform: @escaping (String, String) -> Void) -> Perform { return Perform(method: .m_login__externalToken_externalTokenbackend_backend(`externalToken`, `backend`), performs: perform) } + public static func login(ssoToken: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_login__ssoToken_ssoToken(`ssoToken`), performs: perform) + } public static func resetPassword(email: Parameter, perform: @escaping (String) -> Void) -> Perform { return Perform(method: .m_resetPassword__email_email(`email`), performs: perform) } @@ -609,7 +620,6 @@ 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_ssoLogin__method_method(Parameter) case m_registerClicked case m_signInClicked case m_userSignInClicked @@ -669,7 +679,6 @@ 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 let .m_ssoLogin__method_method(p0): return p0.intValue case .m_registerClicked: return 0 case .m_signInClicked: return 0 case .m_userSignInClicked: return 0 @@ -685,7 +694,6 @@ open class AuthorizationAnalyticsMock: AuthorizationAnalytics, Mock { switch self { case .m_identify__id_idusername_usernameemail_email: return ".identify(id:username:email:)" case .m_userLogin__method_method: return ".userLogin(method:)" - case .m_ssoLogin__method_method: return ".ssoLogin(method:)" case .m_registerClicked: return ".registerClicked()" case .m_signInClicked: return ".signInClicked()" case .m_userSignInClicked: return ".userSignInClicked()" @@ -715,7 +723,6 @@ 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 ssoLogin(method: Parameter) -> Verify { return Verify(method: .m_ssoLogin__method_method(`method`))} 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)} @@ -962,11 +969,11 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { } open func showSSOWebBrowser(title: String) { - addInvocation(.m_showWebBrowser__SSO(Parameter.value(`title`))) - let perform = methodPerformValue(.m_showWebBrowser__SSO(Parameter.value(`title`))) as? (String) -> Void - perform?(`title`) + addInvocation(.m_showSSOWebBrowser__title_title(Parameter.value(`title`))) + let perform = methodPerformValue(.m_showSSOWebBrowser__title_title(Parameter.value(`title`))) as? (String) -> Void + perform?(`title`) } - + open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { addInvocation(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) let perform = methodPerformValue(.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(Parameter.value(`alertTitle`), Parameter.value(`alertMessage`), Parameter.value(`positiveAction`), Parameter<() -> Void>.value(`onCloseTapped`), Parameter<() -> Void>.value(`okTapped`), Parameter.value(`type`))) as? (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void @@ -1006,7 +1013,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { case m_showForgotPasswordScreen case m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(Parameter, Parameter) case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) - case m_showWebBrowser__SSO(Parameter) + case m_showSSOWebBrowser__title_title(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_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) @@ -1072,6 +1079,11 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUrl, rhs: rhsUrl, with: matcher), lhsUrl, rhsUrl, "url")) return Matcher.ComparisonResult(results) + case (.m_showSSOWebBrowser__title_title(let lhsTitle), .m_showSSOWebBrowser__title_title(let rhsTitle)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTitle, rhs: rhsTitle, with: matcher), lhsTitle, rhsTitle, "title")) + return Matcher.ComparisonResult(results) + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let lhsAlerttitle, let lhsAlertmessage, let lhsPositiveaction, let lhsOnclosetapped, let lhsOktapped, let lhsType), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let rhsAlerttitle, let rhsAlertmessage, let rhsPositiveaction, let rhsOnclosetapped, let rhsOktapped, let rhsType)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) @@ -1126,7 +1138,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { case .m_showForgotPasswordScreen: return 0 case let .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(p0, p1): return p0.intValue + p1.intValue case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue - case let .m_showWebBrowser__SSO(p0): return p0.intValue + case let .m_showSSOWebBrowser__title_title(p0): return p0.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_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue @@ -1148,7 +1160,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen: return ".showDiscoveryScreen(searchQuery:sourceScreen:)" case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" - case .m_showWebBrowser__SSO: return ".showSSOWebBrowser(title:)" + case .m_showSSOWebBrowser__title_title: return ".showSSOWebBrowser(title:)" 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_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" @@ -1184,6 +1196,7 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} public static func showDiscoveryScreen(searchQuery: Parameter, sourceScreen: Parameter) -> Verify { return Verify(method: .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(`searchQuery`, `sourceScreen`))} public static func showWebBrowser(title: Parameter, url: Parameter) -> Verify { return Verify(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`))} + public static func showSSOWebBrowser(title: Parameter) -> Verify { return Verify(method: .m_showSSOWebBrowser__title_title(`title`))} 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, completion: Parameter<(() -> Void)?>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`))} @@ -1233,8 +1246,8 @@ open class AuthorizationRouterMock: AuthorizationRouter, Mock { public static func showWebBrowser(title: Parameter, url: Parameter, perform: @escaping (String, URL) -> Void) -> Perform { return Perform(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`), performs: perform) } - public static func showSSOWebBrowser(title: Parameter, perform: @escaping (String, URL) -> Void) -> Perform { - return Perform(method: .m_showWebBrowser__SSO(`title`), performs: perform) + public static func showSSOWebBrowser(title: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_showSSOWebBrowser__title_title(`title`), performs: perform) } public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) @@ -1435,14 +1448,14 @@ open class BaseRouterMock: BaseRouter, Mock { open func showWebBrowser(title: String, url: URL) { addInvocation(.m_showWebBrowser__title_titleurl_url(Parameter.value(`title`), Parameter.value(`url`))) - let perform = methodPerformValue(.m_showWebBrowser__title_titleurl_url(Parameter.value(`title`), Parameter.value(`url`))) as? (String, URL) -> Void - perform?(`title`, `url`) + let perform = methodPerformValue(.m_showWebBrowser__title_titleurl_url(Parameter.value(`title`), Parameter.value(`url`))) as? (String, URL) -> Void + perform?(`title`, `url`) } - + open func showSSOWebBrowser(title: String) { - addInvocation(.m_showWebBrowser__SSO(Parameter.value(`title`))) - let perform = methodPerformValue(.m_showWebBrowser__SSO(Parameter.value(`title`))) as? (String) -> Void - perform?(`title`) + addInvocation(.m_showSSOWebBrowser__title_title(Parameter.value(`title`))) + let perform = methodPerformValue(.m_showSSOWebBrowser__title_title(Parameter.value(`title`))) as? (String) -> Void + perform?(`title`) } open func presentAlert(alertTitle: String, alertMessage: String, positiveAction: String, onCloseTapped: @escaping () -> Void, okTapped: @escaping () -> Void, type: AlertViewType) { @@ -1483,7 +1496,7 @@ open class BaseRouterMock: BaseRouter, Mock { case m_showForgotPasswordScreen case m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(Parameter, Parameter) case m_showWebBrowser__title_titleurl_url(Parameter, Parameter) - case m_showWebBrowser__SSO(Parameter) + case m_showSSOWebBrowser__title_title(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_viewcompletion_completion(Parameter, Parameter, Parameter<(() -> Void)?>) @@ -1544,6 +1557,11 @@ open class BaseRouterMock: BaseRouter, Mock { results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUrl, rhs: rhsUrl, with: matcher), lhsUrl, rhsUrl, "url")) return Matcher.ComparisonResult(results) + case (.m_showSSOWebBrowser__title_title(let lhsTitle), .m_showSSOWebBrowser__title_title(let rhsTitle)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTitle, rhs: rhsTitle, with: matcher), lhsTitle, rhsTitle, "title")) + return Matcher.ComparisonResult(results) + case (.m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let lhsAlerttitle, let lhsAlertmessage, let lhsPositiveaction, let lhsOnclosetapped, let lhsOktapped, let lhsType), .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(let rhsAlerttitle, let rhsAlertmessage, let rhsPositiveaction, let rhsOnclosetapped, let rhsOktapped, let rhsType)): var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAlerttitle, rhs: rhsAlerttitle, with: matcher), lhsAlerttitle, rhsAlerttitle, "alertTitle")) @@ -1597,7 +1615,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return 0 case let .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(p0, p1): return p0.intValue + p1.intValue case let .m_showWebBrowser__title_titleurl_url(p0, p1): return p0.intValue + p1.intValue - case let .m_showWebBrowser__SSO(p0): return p0.intValue + case let .m_showSSOWebBrowser__title_title(p0): return p0.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_viewcompletion_completion(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue @@ -1618,7 +1636,7 @@ open class BaseRouterMock: BaseRouter, Mock { case .m_showForgotPasswordScreen: return ".showForgotPasswordScreen()" case .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen: return ".showDiscoveryScreen(searchQuery:sourceScreen:)" case .m_showWebBrowser__title_titleurl_url: return ".showWebBrowser(title:url:)" - case .m_showWebBrowser__SSO: return ".showSSOWebBrowser(title:)" + case .m_showSSOWebBrowser__title_title: return ".showSSOWebBrowser(title:)" 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_viewcompletion_completion: return ".presentView(transitionStyle:view:completion:)" @@ -1653,6 +1671,7 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showForgotPasswordScreen() -> Verify { return Verify(method: .m_showForgotPasswordScreen)} public static func showDiscoveryScreen(searchQuery: Parameter, sourceScreen: Parameter) -> Verify { return Verify(method: .m_showDiscoveryScreen__searchQuery_searchQuerysourceScreen_sourceScreen(`searchQuery`, `sourceScreen`))} public static func showWebBrowser(title: Parameter, url: Parameter) -> Verify { return Verify(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`))} + public static func showSSOWebBrowser(title: Parameter) -> Verify { return Verify(method: .m_showSSOWebBrowser__title_title(`title`))} 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, completion: Parameter<(() -> Void)?>) -> Verify { return Verify(method: .m_presentView__transitionStyle_transitionStyleview_viewcompletion_completion(`transitionStyle`, `view`, `completion`))} @@ -1699,6 +1718,9 @@ open class BaseRouterMock: BaseRouter, Mock { public static func showWebBrowser(title: Parameter, url: Parameter, perform: @escaping (String, URL) -> Void) -> Perform { return Perform(method: .m_showWebBrowser__title_titleurl_url(`title`, `url`), performs: perform) } + public static func showSSOWebBrowser(title: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_showSSOWebBrowser__title_title(`title`), performs: perform) + } public static func presentAlert(alertTitle: Parameter, alertMessage: Parameter, positiveAction: Parameter, onCloseTapped: Parameter<() -> Void>, okTapped: Parameter<() -> Void>, type: Parameter, perform: @escaping (String, String, String, @escaping () -> Void, @escaping () -> Void, AlertViewType) -> Void) -> Perform { return Perform(method: .m_presentAlert__alertTitle_alertTitlealertMessage_alertMessagepositiveAction_positiveActiononCloseTapped_onCloseTappedokTapped_okTappedtype_type(`alertTitle`, `alertMessage`, `positiveAction`, `onCloseTapped`, `okTapped`, `type`), performs: perform) } @@ -1786,6 +1808,391 @@ open class BaseRouterMock: BaseRouter, Mock { } } +// MARK: - CalendarManagerProtocol + +open class CalendarManagerProtocolMock: CalendarManagerProtocol, 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 createCalendarIfNeeded() { + addInvocation(.m_createCalendarIfNeeded) + let perform = methodPerformValue(.m_createCalendarIfNeeded) as? () -> Void + perform?() + } + + open func filterCoursesBySelected(fetchedCourses: [CourseForSync]) -> [CourseForSync] { + addInvocation(.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>.value(`fetchedCourses`))) + let perform = methodPerformValue(.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>.value(`fetchedCourses`))) as? ([CourseForSync]) -> Void + perform?(`fetchedCourses`) + var __value: [CourseForSync] + do { + __value = try methodReturnValue(.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>.value(`fetchedCourses`))).casted() + } catch { + onFatalFailure("Stub return value not specified for filterCoursesBySelected(fetchedCourses: [CourseForSync]). Use given") + Failure("Stub return value not specified for filterCoursesBySelected(fetchedCourses: [CourseForSync]). Use given") + } + return __value + } + + open func removeOldCalendar() { + addInvocation(.m_removeOldCalendar) + let perform = methodPerformValue(.m_removeOldCalendar) as? () -> Void + perform?() + } + + open func removeOutdatedEvents(courseID: String) { + addInvocation(.m_removeOutdatedEvents__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_removeOutdatedEvents__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + } + + open func syncCourse(courseID: String, courseName: String, dates: CourseDates) { + addInvocation(.m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(Parameter.value(`courseID`), Parameter.value(`courseName`), Parameter.value(`dates`))) + let perform = methodPerformValue(.m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(Parameter.value(`courseID`), Parameter.value(`courseName`), Parameter.value(`dates`))) as? (String, String, CourseDates) -> Void + perform?(`courseID`, `courseName`, `dates`) + } + + open func requestAccess() -> Bool { + addInvocation(.m_requestAccess) + let perform = methodPerformValue(.m_requestAccess) as? () -> Void + perform?() + var __value: Bool + do { + __value = try methodReturnValue(.m_requestAccess).casted() + } catch { + onFatalFailure("Stub return value not specified for requestAccess(). Use given") + Failure("Stub return value not specified for requestAccess(). Use given") + } + return __value + } + + open func courseStatus(courseID: String) -> SyncStatus { + addInvocation(.m_courseStatus__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_courseStatus__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + var __value: SyncStatus + do { + __value = try methodReturnValue(.m_courseStatus__courseID_courseID(Parameter.value(`courseID`))).casted() + } catch { + onFatalFailure("Stub return value not specified for courseStatus(courseID: String). Use given") + Failure("Stub return value not specified for courseStatus(courseID: String). Use given") + } + return __value + } + + open func clearAllData(removeCalendar: Bool) { + addInvocation(.m_clearAllData__removeCalendar_removeCalendar(Parameter.value(`removeCalendar`))) + let perform = methodPerformValue(.m_clearAllData__removeCalendar_removeCalendar(Parameter.value(`removeCalendar`))) as? (Bool) -> Void + perform?(`removeCalendar`) + } + + open func isDatesChanged(courseID: String, checksum: String) -> Bool { + addInvocation(.m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter.value(`courseID`), Parameter.value(`checksum`))) + let perform = methodPerformValue(.m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter.value(`courseID`), Parameter.value(`checksum`))) as? (String, String) -> Void + perform?(`courseID`, `checksum`) + var __value: Bool + do { + __value = try methodReturnValue(.m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter.value(`courseID`), Parameter.value(`checksum`))).casted() + } catch { + onFatalFailure("Stub return value not specified for isDatesChanged(courseID: String, checksum: String). Use given") + Failure("Stub return value not specified for isDatesChanged(courseID: String, checksum: String). Use given") + } + return __value + } + + + fileprivate enum MethodType { + case m_createCalendarIfNeeded + case m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>) + case m_removeOldCalendar + case m_removeOutdatedEvents__courseID_courseID(Parameter) + case m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(Parameter, Parameter, Parameter) + case m_requestAccess + case m_courseStatus__courseID_courseID(Parameter) + case m_clearAllData__removeCalendar_removeCalendar(Parameter) + case m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_createCalendarIfNeeded, .m_createCalendarIfNeeded): return .match + + case (.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(let lhsFetchedcourses), .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(let rhsFetchedcourses)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsFetchedcourses, rhs: rhsFetchedcourses, with: matcher), lhsFetchedcourses, rhsFetchedcourses, "fetchedCourses")) + return Matcher.ComparisonResult(results) + + case (.m_removeOldCalendar, .m_removeOldCalendar): return .match + + case (.m_removeOutdatedEvents__courseID_courseID(let lhsCourseid), .m_removeOutdatedEvents__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_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(let lhsCourseid, let lhsCoursename, let lhsDates), .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(let rhsCourseid, let rhsCoursename, let rhsDates)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDates, rhs: rhsDates, with: matcher), lhsDates, rhsDates, "dates")) + return Matcher.ComparisonResult(results) + + case (.m_requestAccess, .m_requestAccess): return .match + + case (.m_courseStatus__courseID_courseID(let lhsCourseid), .m_courseStatus__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_clearAllData__removeCalendar_removeCalendar(let lhsRemovecalendar), .m_clearAllData__removeCalendar_removeCalendar(let rhsRemovecalendar)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRemovecalendar, rhs: rhsRemovecalendar, with: matcher), lhsRemovecalendar, rhsRemovecalendar, "removeCalendar")) + return Matcher.ComparisonResult(results) + + case (.m_isDatesChanged__courseID_courseIDchecksum_checksum(let lhsCourseid, let lhsChecksum), .m_isDatesChanged__courseID_courseIDchecksum_checksum(let rhsCourseid, let rhsChecksum)): + 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: lhsChecksum, rhs: rhsChecksum, with: matcher), lhsChecksum, rhsChecksum, "checksum")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .m_createCalendarIfNeeded: return 0 + case let .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(p0): return p0.intValue + case .m_removeOldCalendar: return 0 + case let .m_removeOutdatedEvents__courseID_courseID(p0): return p0.intValue + case let .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case .m_requestAccess: return 0 + case let .m_courseStatus__courseID_courseID(p0): return p0.intValue + case let .m_clearAllData__removeCalendar_removeCalendar(p0): return p0.intValue + case let .m_isDatesChanged__courseID_courseIDchecksum_checksum(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_createCalendarIfNeeded: return ".createCalendarIfNeeded()" + case .m_filterCoursesBySelected__fetchedCourses_fetchedCourses: return ".filterCoursesBySelected(fetchedCourses:)" + case .m_removeOldCalendar: return ".removeOldCalendar()" + case .m_removeOutdatedEvents__courseID_courseID: return ".removeOutdatedEvents(courseID:)" + case .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates: return ".syncCourse(courseID:courseName:dates:)" + case .m_requestAccess: return ".requestAccess()" + case .m_courseStatus__courseID_courseID: return ".courseStatus(courseID:)" + case .m_clearAllData__removeCalendar_removeCalendar: return ".clearAllData(removeCalendar:)" + case .m_isDatesChanged__courseID_courseIDchecksum_checksum: return ".isDatesChanged(courseID:checksum:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>, willReturn: [CourseForSync]...) -> MethodStub { + return Given(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func requestAccess(willReturn: Bool...) -> MethodStub { + return Given(method: .m_requestAccess, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func courseStatus(courseID: Parameter, willReturn: SyncStatus...) -> MethodStub { + return Given(method: .m_courseStatus__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func isDatesChanged(courseID: Parameter, checksum: Parameter, willReturn: Bool...) -> MethodStub { + return Given(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>, willProduce: (Stubber<[CourseForSync]>) -> Void) -> MethodStub { + let willReturn: [[CourseForSync]] = [] + let given: Given = { return Given(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseForSync]).self) + willProduce(stubber) + return given + } + public static func requestAccess(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Bool] = [] + let given: Given = { return Given(method: .m_requestAccess, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Bool).self) + willProduce(stubber) + return given + } + public static func courseStatus(courseID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [SyncStatus] = [] + let given: Given = { return Given(method: .m_courseStatus__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (SyncStatus).self) + willProduce(stubber) + return given + } + public static func isDatesChanged(courseID: Parameter, checksum: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Bool] = [] + let given: Given = { return Given(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Bool).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func createCalendarIfNeeded() -> Verify { return Verify(method: .m_createCalendarIfNeeded)} + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>) -> Verify { return Verify(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`))} + public static func removeOldCalendar() -> Verify { return Verify(method: .m_removeOldCalendar)} + public static func removeOutdatedEvents(courseID: Parameter) -> Verify { return Verify(method: .m_removeOutdatedEvents__courseID_courseID(`courseID`))} + public static func syncCourse(courseID: Parameter, courseName: Parameter, dates: Parameter) -> Verify { return Verify(method: .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(`courseID`, `courseName`, `dates`))} + public static func requestAccess() -> Verify { return Verify(method: .m_requestAccess)} + public static func courseStatus(courseID: Parameter) -> Verify { return Verify(method: .m_courseStatus__courseID_courseID(`courseID`))} + public static func clearAllData(removeCalendar: Parameter) -> Verify { return Verify(method: .m_clearAllData__removeCalendar_removeCalendar(`removeCalendar`))} + public static func isDatesChanged(courseID: Parameter, checksum: Parameter) -> Verify { return Verify(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func createCalendarIfNeeded(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_createCalendarIfNeeded, performs: perform) + } + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>, perform: @escaping ([CourseForSync]) -> Void) -> Perform { + return Perform(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`), performs: perform) + } + public static func removeOldCalendar(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_removeOldCalendar, performs: perform) + } + public static func removeOutdatedEvents(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_removeOutdatedEvents__courseID_courseID(`courseID`), performs: perform) + } + public static func syncCourse(courseID: Parameter, courseName: Parameter, dates: Parameter, perform: @escaping (String, String, CourseDates) -> Void) -> Perform { + return Perform(method: .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(`courseID`, `courseName`, `dates`), performs: perform) + } + public static func requestAccess(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_requestAccess, performs: perform) + } + public static func courseStatus(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_courseStatus__courseID_courseID(`courseID`), performs: perform) + } + public static func clearAllData(removeCalendar: Parameter, perform: @escaping (Bool) -> Void) -> Perform { + return Perform(method: .m_clearAllData__removeCalendar_removeCalendar(`removeCalendar`), performs: perform) + } + public static func isDatesChanged(courseID: Parameter, checksum: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`), 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: - ConnectivityProtocol open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { diff --git a/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift index 371ac62b7..1435a61b9 100644 --- a/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift @@ -109,11 +109,11 @@ final class SignInViewModelTests: XCTestCase { ) let user = User(id: 1, username: "username", email: "edxUser@edx.com", name: "Name", userAvatar: "") - Given(interactor, .ssoLogin(title: .any, willReturn: user)) + Given(interactor, .login(ssoToken: .any, willReturn: user)) await viewModel.ssoLogin(title: "Riyadah") - Verify(interactor, 1, .ssoLogin(title: .any)) + Verify(interactor, 1, .login(ssoToken: .any)) Verify(router, 1, .showMainOrWhatsNewScreen(sourceScreen: .any)) XCTAssertEqual(viewModel.errorMessage, nil) diff --git a/Core/Core/Configuration/Config/Config.swift b/Core/Core/Configuration/Config/Config.swift index 8c0cd2d18..adad33a0d 100644 --- a/Core/Core/Configuration/Config/Config.swift +++ b/Core/Core/Configuration/Config/Config.swift @@ -7,6 +7,7 @@ import Foundation +//sourcery: AutoMockable public protocol ConfigProtocol { var baseURL: URL { get } var baseSSOURL: URL { get } diff --git a/Core/Core/Domain/Model/CourseForSync.swift b/Core/Core/Domain/Model/CourseForSync.swift index d3d7af491..5f3a6c343 100644 --- a/Core/Core/Domain/Model/CourseForSync.swift +++ b/Core/Core/Domain/Model/CourseForSync.swift @@ -41,3 +41,11 @@ extension DataLayer.EnrollmentsStatus { } } +extension CourseForSync: Equatable { + public static func == (lhs: CourseForSync, rhs: CourseForSync) -> Bool { + return lhs.courseID == rhs.courseID && + lhs.name == rhs.name && + lhs.synced == rhs.synced && + lhs.recentlyActive == rhs.recentlyActive + } +} diff --git a/Core/Core/View/Base/CalendarManagerProtocol.swift b/Core/Core/View/Base/CalendarManagerProtocol.swift index ef7fdcb3e..152c0dc21 100644 --- a/Core/Core/View/Base/CalendarManagerProtocol.swift +++ b/Core/Core/View/Base/CalendarManagerProtocol.swift @@ -7,6 +7,7 @@ import Foundation +//sourcery: AutoMockable public protocol CalendarManagerProtocol { func createCalendarIfNeeded() func filterCoursesBySelected(fetchedCourses: [CourseForSync]) async -> [CourseForSync] diff --git a/Core/CoreTests/CoreMock.generated.swift b/Core/CoreTests/CoreMock.generated.swift index 4ec87ace6..3fbb5c626 100644 --- a/Core/CoreTests/CoreMock.generated.swift +++ b/Core/CoreTests/CoreMock.generated.swift @@ -977,6 +977,899 @@ open class BaseRouterMock: BaseRouter, Mock { } } +// MARK: - CalendarManagerProtocol + +open class CalendarManagerProtocolMock: CalendarManagerProtocol, 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 createCalendarIfNeeded() { + addInvocation(.m_createCalendarIfNeeded) + let perform = methodPerformValue(.m_createCalendarIfNeeded) as? () -> Void + perform?() + } + + open func filterCoursesBySelected(fetchedCourses: [CourseForSync]) -> [CourseForSync] { + addInvocation(.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>.value(`fetchedCourses`))) + let perform = methodPerformValue(.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>.value(`fetchedCourses`))) as? ([CourseForSync]) -> Void + perform?(`fetchedCourses`) + var __value: [CourseForSync] + do { + __value = try methodReturnValue(.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>.value(`fetchedCourses`))).casted() + } catch { + onFatalFailure("Stub return value not specified for filterCoursesBySelected(fetchedCourses: [CourseForSync]). Use given") + Failure("Stub return value not specified for filterCoursesBySelected(fetchedCourses: [CourseForSync]). Use given") + } + return __value + } + + open func removeOldCalendar() { + addInvocation(.m_removeOldCalendar) + let perform = methodPerformValue(.m_removeOldCalendar) as? () -> Void + perform?() + } + + open func removeOutdatedEvents(courseID: String) { + addInvocation(.m_removeOutdatedEvents__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_removeOutdatedEvents__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + } + + open func syncCourse(courseID: String, courseName: String, dates: CourseDates) { + addInvocation(.m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(Parameter.value(`courseID`), Parameter.value(`courseName`), Parameter.value(`dates`))) + let perform = methodPerformValue(.m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(Parameter.value(`courseID`), Parameter.value(`courseName`), Parameter.value(`dates`))) as? (String, String, CourseDates) -> Void + perform?(`courseID`, `courseName`, `dates`) + } + + open func requestAccess() -> Bool { + addInvocation(.m_requestAccess) + let perform = methodPerformValue(.m_requestAccess) as? () -> Void + perform?() + var __value: Bool + do { + __value = try methodReturnValue(.m_requestAccess).casted() + } catch { + onFatalFailure("Stub return value not specified for requestAccess(). Use given") + Failure("Stub return value not specified for requestAccess(). Use given") + } + return __value + } + + open func courseStatus(courseID: String) -> SyncStatus { + addInvocation(.m_courseStatus__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_courseStatus__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + var __value: SyncStatus + do { + __value = try methodReturnValue(.m_courseStatus__courseID_courseID(Parameter.value(`courseID`))).casted() + } catch { + onFatalFailure("Stub return value not specified for courseStatus(courseID: String). Use given") + Failure("Stub return value not specified for courseStatus(courseID: String). Use given") + } + return __value + } + + open func clearAllData(removeCalendar: Bool) { + addInvocation(.m_clearAllData__removeCalendar_removeCalendar(Parameter.value(`removeCalendar`))) + let perform = methodPerformValue(.m_clearAllData__removeCalendar_removeCalendar(Parameter.value(`removeCalendar`))) as? (Bool) -> Void + perform?(`removeCalendar`) + } + + open func isDatesChanged(courseID: String, checksum: String) -> Bool { + addInvocation(.m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter.value(`courseID`), Parameter.value(`checksum`))) + let perform = methodPerformValue(.m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter.value(`courseID`), Parameter.value(`checksum`))) as? (String, String) -> Void + perform?(`courseID`, `checksum`) + var __value: Bool + do { + __value = try methodReturnValue(.m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter.value(`courseID`), Parameter.value(`checksum`))).casted() + } catch { + onFatalFailure("Stub return value not specified for isDatesChanged(courseID: String, checksum: String). Use given") + Failure("Stub return value not specified for isDatesChanged(courseID: String, checksum: String). Use given") + } + return __value + } + + + fileprivate enum MethodType { + case m_createCalendarIfNeeded + case m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>) + case m_removeOldCalendar + case m_removeOutdatedEvents__courseID_courseID(Parameter) + case m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(Parameter, Parameter, Parameter) + case m_requestAccess + case m_courseStatus__courseID_courseID(Parameter) + case m_clearAllData__removeCalendar_removeCalendar(Parameter) + case m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_createCalendarIfNeeded, .m_createCalendarIfNeeded): return .match + + case (.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(let lhsFetchedcourses), .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(let rhsFetchedcourses)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsFetchedcourses, rhs: rhsFetchedcourses, with: matcher), lhsFetchedcourses, rhsFetchedcourses, "fetchedCourses")) + return Matcher.ComparisonResult(results) + + case (.m_removeOldCalendar, .m_removeOldCalendar): return .match + + case (.m_removeOutdatedEvents__courseID_courseID(let lhsCourseid), .m_removeOutdatedEvents__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_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(let lhsCourseid, let lhsCoursename, let lhsDates), .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(let rhsCourseid, let rhsCoursename, let rhsDates)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDates, rhs: rhsDates, with: matcher), lhsDates, rhsDates, "dates")) + return Matcher.ComparisonResult(results) + + case (.m_requestAccess, .m_requestAccess): return .match + + case (.m_courseStatus__courseID_courseID(let lhsCourseid), .m_courseStatus__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_clearAllData__removeCalendar_removeCalendar(let lhsRemovecalendar), .m_clearAllData__removeCalendar_removeCalendar(let rhsRemovecalendar)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRemovecalendar, rhs: rhsRemovecalendar, with: matcher), lhsRemovecalendar, rhsRemovecalendar, "removeCalendar")) + return Matcher.ComparisonResult(results) + + case (.m_isDatesChanged__courseID_courseIDchecksum_checksum(let lhsCourseid, let lhsChecksum), .m_isDatesChanged__courseID_courseIDchecksum_checksum(let rhsCourseid, let rhsChecksum)): + 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: lhsChecksum, rhs: rhsChecksum, with: matcher), lhsChecksum, rhsChecksum, "checksum")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .m_createCalendarIfNeeded: return 0 + case let .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(p0): return p0.intValue + case .m_removeOldCalendar: return 0 + case let .m_removeOutdatedEvents__courseID_courseID(p0): return p0.intValue + case let .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case .m_requestAccess: return 0 + case let .m_courseStatus__courseID_courseID(p0): return p0.intValue + case let .m_clearAllData__removeCalendar_removeCalendar(p0): return p0.intValue + case let .m_isDatesChanged__courseID_courseIDchecksum_checksum(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_createCalendarIfNeeded: return ".createCalendarIfNeeded()" + case .m_filterCoursesBySelected__fetchedCourses_fetchedCourses: return ".filterCoursesBySelected(fetchedCourses:)" + case .m_removeOldCalendar: return ".removeOldCalendar()" + case .m_removeOutdatedEvents__courseID_courseID: return ".removeOutdatedEvents(courseID:)" + case .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates: return ".syncCourse(courseID:courseName:dates:)" + case .m_requestAccess: return ".requestAccess()" + case .m_courseStatus__courseID_courseID: return ".courseStatus(courseID:)" + case .m_clearAllData__removeCalendar_removeCalendar: return ".clearAllData(removeCalendar:)" + case .m_isDatesChanged__courseID_courseIDchecksum_checksum: return ".isDatesChanged(courseID:checksum:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>, willReturn: [CourseForSync]...) -> MethodStub { + return Given(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func requestAccess(willReturn: Bool...) -> MethodStub { + return Given(method: .m_requestAccess, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func courseStatus(courseID: Parameter, willReturn: SyncStatus...) -> MethodStub { + return Given(method: .m_courseStatus__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func isDatesChanged(courseID: Parameter, checksum: Parameter, willReturn: Bool...) -> MethodStub { + return Given(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>, willProduce: (Stubber<[CourseForSync]>) -> Void) -> MethodStub { + let willReturn: [[CourseForSync]] = [] + let given: Given = { return Given(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseForSync]).self) + willProduce(stubber) + return given + } + public static func requestAccess(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Bool] = [] + let given: Given = { return Given(method: .m_requestAccess, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Bool).self) + willProduce(stubber) + return given + } + public static func courseStatus(courseID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [SyncStatus] = [] + let given: Given = { return Given(method: .m_courseStatus__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (SyncStatus).self) + willProduce(stubber) + return given + } + public static func isDatesChanged(courseID: Parameter, checksum: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Bool] = [] + let given: Given = { return Given(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Bool).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func createCalendarIfNeeded() -> Verify { return Verify(method: .m_createCalendarIfNeeded)} + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>) -> Verify { return Verify(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`))} + public static func removeOldCalendar() -> Verify { return Verify(method: .m_removeOldCalendar)} + public static func removeOutdatedEvents(courseID: Parameter) -> Verify { return Verify(method: .m_removeOutdatedEvents__courseID_courseID(`courseID`))} + public static func syncCourse(courseID: Parameter, courseName: Parameter, dates: Parameter) -> Verify { return Verify(method: .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(`courseID`, `courseName`, `dates`))} + public static func requestAccess() -> Verify { return Verify(method: .m_requestAccess)} + public static func courseStatus(courseID: Parameter) -> Verify { return Verify(method: .m_courseStatus__courseID_courseID(`courseID`))} + public static func clearAllData(removeCalendar: Parameter) -> Verify { return Verify(method: .m_clearAllData__removeCalendar_removeCalendar(`removeCalendar`))} + public static func isDatesChanged(courseID: Parameter, checksum: Parameter) -> Verify { return Verify(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func createCalendarIfNeeded(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_createCalendarIfNeeded, performs: perform) + } + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>, perform: @escaping ([CourseForSync]) -> Void) -> Perform { + return Perform(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`), performs: perform) + } + public static func removeOldCalendar(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_removeOldCalendar, performs: perform) + } + public static func removeOutdatedEvents(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_removeOutdatedEvents__courseID_courseID(`courseID`), performs: perform) + } + public static func syncCourse(courseID: Parameter, courseName: Parameter, dates: Parameter, perform: @escaping (String, String, CourseDates) -> Void) -> Perform { + return Perform(method: .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(`courseID`, `courseName`, `dates`), performs: perform) + } + public static func requestAccess(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_requestAccess, performs: perform) + } + public static func courseStatus(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_courseStatus__courseID_courseID(`courseID`), performs: perform) + } + public static func clearAllData(removeCalendar: Parameter, perform: @escaping (Bool) -> Void) -> Perform { + return Perform(method: .m_clearAllData__removeCalendar_removeCalendar(`removeCalendar`), performs: perform) + } + public static func isDatesChanged(courseID: Parameter, checksum: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`), 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: - ConfigProtocol + +open class ConfigProtocolMock: ConfigProtocol, 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 baseURL: URL { + get { invocations.append(.p_baseURL_get); return __p_baseURL ?? givenGetterValue(.p_baseURL_get, "ConfigProtocolMock - stub value for baseURL was not defined") } + } + private var __p_baseURL: (URL)? + + public var baseSSOURL: URL { + get { invocations.append(.p_baseSSOURL_get); return __p_baseSSOURL ?? givenGetterValue(.p_baseSSOURL_get, "ConfigProtocolMock - stub value for baseSSOURL was not defined") } + } + private var __p_baseSSOURL: (URL)? + + public var ssoFinishedURL: URL { + get { invocations.append(.p_ssoFinishedURL_get); return __p_ssoFinishedURL ?? givenGetterValue(.p_ssoFinishedURL_get, "ConfigProtocolMock - stub value for ssoFinishedURL was not defined") } + } + private var __p_ssoFinishedURL: (URL)? + + public var ssoButtonTitle: [String: Any] { + get { invocations.append(.p_ssoButtonTitle_get); return __p_ssoButtonTitle ?? givenGetterValue(.p_ssoButtonTitle_get, "ConfigProtocolMock - stub value for ssoButtonTitle was not defined") } + } + private var __p_ssoButtonTitle: ([String: Any])? + + public var oAuthClientId: String { + get { invocations.append(.p_oAuthClientId_get); return __p_oAuthClientId ?? givenGetterValue(.p_oAuthClientId_get, "ConfigProtocolMock - stub value for oAuthClientId was not defined") } + } + private var __p_oAuthClientId: (String)? + + public var tokenType: TokenType { + get { invocations.append(.p_tokenType_get); return __p_tokenType ?? givenGetterValue(.p_tokenType_get, "ConfigProtocolMock - stub value for tokenType was not defined") } + } + private var __p_tokenType: (TokenType)? + + public var feedbackEmail: String { + get { invocations.append(.p_feedbackEmail_get); return __p_feedbackEmail ?? givenGetterValue(.p_feedbackEmail_get, "ConfigProtocolMock - stub value for feedbackEmail was not defined") } + } + private var __p_feedbackEmail: (String)? + + public var appStoreLink: String { + get { invocations.append(.p_appStoreLink_get); return __p_appStoreLink ?? givenGetterValue(.p_appStoreLink_get, "ConfigProtocolMock - stub value for appStoreLink was not defined") } + } + private var __p_appStoreLink: (String)? + + public var faq: URL? { + get { invocations.append(.p_faq_get); return __p_faq ?? optionalGivenGetterValue(.p_faq_get, "ConfigProtocolMock - stub value for faq was not defined") } + } + private var __p_faq: (URL)? + + public var platformName: String { + get { invocations.append(.p_platformName_get); return __p_platformName ?? givenGetterValue(.p_platformName_get, "ConfigProtocolMock - stub value for platformName was not defined") } + } + private var __p_platformName: (String)? + + public var agreement: AgreementConfig { + get { invocations.append(.p_agreement_get); return __p_agreement ?? givenGetterValue(.p_agreement_get, "ConfigProtocolMock - stub value for agreement was not defined") } + } + private var __p_agreement: (AgreementConfig)? + + public var firebase: FirebaseConfig { + get { invocations.append(.p_firebase_get); return __p_firebase ?? givenGetterValue(.p_firebase_get, "ConfigProtocolMock - stub value for firebase was not defined") } + } + private var __p_firebase: (FirebaseConfig)? + + public var facebook: FacebookConfig { + get { invocations.append(.p_facebook_get); return __p_facebook ?? givenGetterValue(.p_facebook_get, "ConfigProtocolMock - stub value for facebook was not defined") } + } + private var __p_facebook: (FacebookConfig)? + + public var microsoft: MicrosoftConfig { + get { invocations.append(.p_microsoft_get); return __p_microsoft ?? givenGetterValue(.p_microsoft_get, "ConfigProtocolMock - stub value for microsoft was not defined") } + } + private var __p_microsoft: (MicrosoftConfig)? + + public var google: GoogleConfig { + get { invocations.append(.p_google_get); return __p_google ?? givenGetterValue(.p_google_get, "ConfigProtocolMock - stub value for google was not defined") } + } + private var __p_google: (GoogleConfig)? + + public var appleSignIn: AppleSignInConfig { + get { invocations.append(.p_appleSignIn_get); return __p_appleSignIn ?? givenGetterValue(.p_appleSignIn_get, "ConfigProtocolMock - stub value for appleSignIn was not defined") } + } + private var __p_appleSignIn: (AppleSignInConfig)? + + public var features: FeaturesConfig { + get { invocations.append(.p_features_get); return __p_features ?? givenGetterValue(.p_features_get, "ConfigProtocolMock - stub value for features was not defined") } + } + private var __p_features: (FeaturesConfig)? + + public var theme: ThemeConfig { + get { invocations.append(.p_theme_get); return __p_theme ?? givenGetterValue(.p_theme_get, "ConfigProtocolMock - stub value for theme was not defined") } + } + private var __p_theme: (ThemeConfig)? + + public var uiComponents: UIComponentsConfig { + get { invocations.append(.p_uiComponents_get); return __p_uiComponents ?? givenGetterValue(.p_uiComponents_get, "ConfigProtocolMock - stub value for uiComponents was not defined") } + } + private var __p_uiComponents: (UIComponentsConfig)? + + public var discovery: DiscoveryConfig { + get { invocations.append(.p_discovery_get); return __p_discovery ?? givenGetterValue(.p_discovery_get, "ConfigProtocolMock - stub value for discovery was not defined") } + } + private var __p_discovery: (DiscoveryConfig)? + + public var dashboard: DashboardConfig { + get { invocations.append(.p_dashboard_get); return __p_dashboard ?? givenGetterValue(.p_dashboard_get, "ConfigProtocolMock - stub value for dashboard was not defined") } + } + private var __p_dashboard: (DashboardConfig)? + + public var braze: BrazeConfig { + get { invocations.append(.p_braze_get); return __p_braze ?? givenGetterValue(.p_braze_get, "ConfigProtocolMock - stub value for braze was not defined") } + } + private var __p_braze: (BrazeConfig)? + + public var branch: BranchConfig { + get { invocations.append(.p_branch_get); return __p_branch ?? givenGetterValue(.p_branch_get, "ConfigProtocolMock - stub value for branch was not defined") } + } + private var __p_branch: (BranchConfig)? + + public var segment: SegmentConfig { + get { invocations.append(.p_segment_get); return __p_segment ?? givenGetterValue(.p_segment_get, "ConfigProtocolMock - stub value for segment was not defined") } + } + private var __p_segment: (SegmentConfig)? + + public var program: DiscoveryConfig { + get { invocations.append(.p_program_get); return __p_program ?? givenGetterValue(.p_program_get, "ConfigProtocolMock - stub value for program was not defined") } + } + private var __p_program: (DiscoveryConfig)? + + public var URIScheme: String { + get { invocations.append(.p_URIScheme_get); return __p_URIScheme ?? givenGetterValue(.p_URIScheme_get, "ConfigProtocolMock - stub value for URIScheme was not defined") } + } + private var __p_URIScheme: (String)? + + public var fullStory: FullStoryConfig { + get { invocations.append(.p_fullStory_get); return __p_fullStory ?? givenGetterValue(.p_fullStory_get, "ConfigProtocolMock - stub value for fullStory was not defined") } + } + private var __p_fullStory: (FullStoryConfig)? + + + + + + + fileprivate enum MethodType { + case p_baseURL_get + case p_baseSSOURL_get + case p_ssoFinishedURL_get + case p_ssoButtonTitle_get + case p_oAuthClientId_get + case p_tokenType_get + case p_feedbackEmail_get + case p_appStoreLink_get + case p_faq_get + case p_platformName_get + case p_agreement_get + case p_firebase_get + case p_facebook_get + case p_microsoft_get + case p_google_get + case p_appleSignIn_get + case p_features_get + case p_theme_get + case p_uiComponents_get + case p_discovery_get + case p_dashboard_get + case p_braze_get + case p_branch_get + case p_segment_get + case p_program_get + case p_URIScheme_get + case p_fullStory_get + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { case (.p_baseURL_get,.p_baseURL_get): return Matcher.ComparisonResult.match + case (.p_baseSSOURL_get,.p_baseSSOURL_get): return Matcher.ComparisonResult.match + case (.p_ssoFinishedURL_get,.p_ssoFinishedURL_get): return Matcher.ComparisonResult.match + case (.p_ssoButtonTitle_get,.p_ssoButtonTitle_get): return Matcher.ComparisonResult.match + case (.p_oAuthClientId_get,.p_oAuthClientId_get): return Matcher.ComparisonResult.match + case (.p_tokenType_get,.p_tokenType_get): return Matcher.ComparisonResult.match + case (.p_feedbackEmail_get,.p_feedbackEmail_get): return Matcher.ComparisonResult.match + case (.p_appStoreLink_get,.p_appStoreLink_get): return Matcher.ComparisonResult.match + case (.p_faq_get,.p_faq_get): return Matcher.ComparisonResult.match + case (.p_platformName_get,.p_platformName_get): return Matcher.ComparisonResult.match + case (.p_agreement_get,.p_agreement_get): return Matcher.ComparisonResult.match + case (.p_firebase_get,.p_firebase_get): return Matcher.ComparisonResult.match + case (.p_facebook_get,.p_facebook_get): return Matcher.ComparisonResult.match + case (.p_microsoft_get,.p_microsoft_get): return Matcher.ComparisonResult.match + case (.p_google_get,.p_google_get): return Matcher.ComparisonResult.match + case (.p_appleSignIn_get,.p_appleSignIn_get): return Matcher.ComparisonResult.match + case (.p_features_get,.p_features_get): return Matcher.ComparisonResult.match + case (.p_theme_get,.p_theme_get): return Matcher.ComparisonResult.match + case (.p_uiComponents_get,.p_uiComponents_get): return Matcher.ComparisonResult.match + case (.p_discovery_get,.p_discovery_get): return Matcher.ComparisonResult.match + case (.p_dashboard_get,.p_dashboard_get): return Matcher.ComparisonResult.match + case (.p_braze_get,.p_braze_get): return Matcher.ComparisonResult.match + case (.p_branch_get,.p_branch_get): return Matcher.ComparisonResult.match + case (.p_segment_get,.p_segment_get): return Matcher.ComparisonResult.match + case (.p_program_get,.p_program_get): return Matcher.ComparisonResult.match + case (.p_URIScheme_get,.p_URIScheme_get): return Matcher.ComparisonResult.match + case (.p_fullStory_get,.p_fullStory_get): return Matcher.ComparisonResult.match + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .p_baseURL_get: return 0 + case .p_baseSSOURL_get: return 0 + case .p_ssoFinishedURL_get: return 0 + case .p_ssoButtonTitle_get: return 0 + case .p_oAuthClientId_get: return 0 + case .p_tokenType_get: return 0 + case .p_feedbackEmail_get: return 0 + case .p_appStoreLink_get: return 0 + case .p_faq_get: return 0 + case .p_platformName_get: return 0 + case .p_agreement_get: return 0 + case .p_firebase_get: return 0 + case .p_facebook_get: return 0 + case .p_microsoft_get: return 0 + case .p_google_get: return 0 + case .p_appleSignIn_get: return 0 + case .p_features_get: return 0 + case .p_theme_get: return 0 + case .p_uiComponents_get: return 0 + case .p_discovery_get: return 0 + case .p_dashboard_get: return 0 + case .p_braze_get: return 0 + case .p_branch_get: return 0 + case .p_segment_get: return 0 + case .p_program_get: return 0 + case .p_URIScheme_get: return 0 + case .p_fullStory_get: return 0 + } + } + func assertionName() -> String { + switch self { + case .p_baseURL_get: return "[get] .baseURL" + case .p_baseSSOURL_get: return "[get] .baseSSOURL" + case .p_ssoFinishedURL_get: return "[get] .ssoFinishedURL" + case .p_ssoButtonTitle_get: return "[get] .ssoButtonTitle" + case .p_oAuthClientId_get: return "[get] .oAuthClientId" + case .p_tokenType_get: return "[get] .tokenType" + case .p_feedbackEmail_get: return "[get] .feedbackEmail" + case .p_appStoreLink_get: return "[get] .appStoreLink" + case .p_faq_get: return "[get] .faq" + case .p_platformName_get: return "[get] .platformName" + case .p_agreement_get: return "[get] .agreement" + case .p_firebase_get: return "[get] .firebase" + case .p_facebook_get: return "[get] .facebook" + case .p_microsoft_get: return "[get] .microsoft" + case .p_google_get: return "[get] .google" + case .p_appleSignIn_get: return "[get] .appleSignIn" + case .p_features_get: return "[get] .features" + case .p_theme_get: return "[get] .theme" + case .p_uiComponents_get: return "[get] .uiComponents" + case .p_discovery_get: return "[get] .discovery" + case .p_dashboard_get: return "[get] .dashboard" + case .p_braze_get: return "[get] .braze" + case .p_branch_get: return "[get] .branch" + case .p_segment_get: return "[get] .segment" + case .p_program_get: return "[get] .program" + case .p_URIScheme_get: return "[get] .URIScheme" + case .p_fullStory_get: return "[get] .fullStory" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func baseURL(getter defaultValue: URL...) -> PropertyStub { + return Given(method: .p_baseURL_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func baseSSOURL(getter defaultValue: URL...) -> PropertyStub { + return Given(method: .p_baseSSOURL_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func ssoFinishedURL(getter defaultValue: URL...) -> PropertyStub { + return Given(method: .p_ssoFinishedURL_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func ssoButtonTitle(getter defaultValue: [String: Any]...) -> PropertyStub { + return Given(method: .p_ssoButtonTitle_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func oAuthClientId(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_oAuthClientId_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func tokenType(getter defaultValue: TokenType...) -> PropertyStub { + return Given(method: .p_tokenType_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func feedbackEmail(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_feedbackEmail_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appStoreLink(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_appStoreLink_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func faq(getter defaultValue: URL?...) -> PropertyStub { + return Given(method: .p_faq_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func platformName(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_platformName_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func agreement(getter defaultValue: AgreementConfig...) -> PropertyStub { + return Given(method: .p_agreement_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func firebase(getter defaultValue: FirebaseConfig...) -> PropertyStub { + return Given(method: .p_firebase_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func facebook(getter defaultValue: FacebookConfig...) -> PropertyStub { + return Given(method: .p_facebook_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func microsoft(getter defaultValue: MicrosoftConfig...) -> PropertyStub { + return Given(method: .p_microsoft_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func google(getter defaultValue: GoogleConfig...) -> PropertyStub { + return Given(method: .p_google_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appleSignIn(getter defaultValue: AppleSignInConfig...) -> PropertyStub { + return Given(method: .p_appleSignIn_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func features(getter defaultValue: FeaturesConfig...) -> PropertyStub { + return Given(method: .p_features_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func theme(getter defaultValue: ThemeConfig...) -> PropertyStub { + return Given(method: .p_theme_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func uiComponents(getter defaultValue: UIComponentsConfig...) -> PropertyStub { + return Given(method: .p_uiComponents_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func discovery(getter defaultValue: DiscoveryConfig...) -> PropertyStub { + return Given(method: .p_discovery_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func dashboard(getter defaultValue: DashboardConfig...) -> PropertyStub { + return Given(method: .p_dashboard_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func braze(getter defaultValue: BrazeConfig...) -> PropertyStub { + return Given(method: .p_braze_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func branch(getter defaultValue: BranchConfig...) -> PropertyStub { + return Given(method: .p_branch_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func segment(getter defaultValue: SegmentConfig...) -> PropertyStub { + return Given(method: .p_segment_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func program(getter defaultValue: DiscoveryConfig...) -> PropertyStub { + return Given(method: .p_program_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func URIScheme(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_URIScheme_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func fullStory(getter defaultValue: FullStoryConfig...) -> PropertyStub { + return Given(method: .p_fullStory_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static var baseURL: Verify { return Verify(method: .p_baseURL_get) } + public static var baseSSOURL: Verify { return Verify(method: .p_baseSSOURL_get) } + public static var ssoFinishedURL: Verify { return Verify(method: .p_ssoFinishedURL_get) } + public static var ssoButtonTitle: Verify { return Verify(method: .p_ssoButtonTitle_get) } + public static var oAuthClientId: Verify { return Verify(method: .p_oAuthClientId_get) } + public static var tokenType: Verify { return Verify(method: .p_tokenType_get) } + public static var feedbackEmail: Verify { return Verify(method: .p_feedbackEmail_get) } + public static var appStoreLink: Verify { return Verify(method: .p_appStoreLink_get) } + public static var faq: Verify { return Verify(method: .p_faq_get) } + public static var platformName: Verify { return Verify(method: .p_platformName_get) } + public static var agreement: Verify { return Verify(method: .p_agreement_get) } + public static var firebase: Verify { return Verify(method: .p_firebase_get) } + public static var facebook: Verify { return Verify(method: .p_facebook_get) } + public static var microsoft: Verify { return Verify(method: .p_microsoft_get) } + public static var google: Verify { return Verify(method: .p_google_get) } + public static var appleSignIn: Verify { return Verify(method: .p_appleSignIn_get) } + public static var features: Verify { return Verify(method: .p_features_get) } + public static var theme: Verify { return Verify(method: .p_theme_get) } + public static var uiComponents: Verify { return Verify(method: .p_uiComponents_get) } + public static var discovery: Verify { return Verify(method: .p_discovery_get) } + public static var dashboard: Verify { return Verify(method: .p_dashboard_get) } + public static var braze: Verify { return Verify(method: .p_braze_get) } + public static var branch: Verify { return Verify(method: .p_branch_get) } + public static var segment: Verify { return Verify(method: .p_segment_get) } + public static var program: Verify { return Verify(method: .p_program_get) } + public static var URIScheme: Verify { return Verify(method: .p_URIScheme_get) } + public static var fullStory: Verify { return Verify(method: .p_fullStory_get) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + } + + 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: - ConnectivityProtocol open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { diff --git a/Core/CoreTests/DownloadManager/DownloadManagerTests.swift b/Core/CoreTests/DownloadManager/DownloadManagerTests.swift index 1b6d03b62..67e0aa644 100644 --- a/Core/CoreTests/DownloadManager/DownloadManagerTests.swift +++ b/Core/CoreTests/DownloadManager/DownloadManagerTests.swift @@ -100,6 +100,9 @@ final class DownloadManagerTests: XCTestCase { // When try await downloadManager.resumeDownloading() + // Wait a bit for async operations to complete + try? await Task.sleep(nanoseconds: 100_000_000) + // Then Verify(persistence, 2, .nextBlockForDownloading()) XCTAssertEqual(downloadManager.currentDownloadTask?.id, mockTask.id) diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index ca2964927..9033a7383 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -978,6 +978,899 @@ open class BaseRouterMock: BaseRouter, Mock { } } +// MARK: - CalendarManagerProtocol + +open class CalendarManagerProtocolMock: CalendarManagerProtocol, 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 createCalendarIfNeeded() { + addInvocation(.m_createCalendarIfNeeded) + let perform = methodPerformValue(.m_createCalendarIfNeeded) as? () -> Void + perform?() + } + + open func filterCoursesBySelected(fetchedCourses: [CourseForSync]) -> [CourseForSync] { + addInvocation(.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>.value(`fetchedCourses`))) + let perform = methodPerformValue(.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>.value(`fetchedCourses`))) as? ([CourseForSync]) -> Void + perform?(`fetchedCourses`) + var __value: [CourseForSync] + do { + __value = try methodReturnValue(.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>.value(`fetchedCourses`))).casted() + } catch { + onFatalFailure("Stub return value not specified for filterCoursesBySelected(fetchedCourses: [CourseForSync]). Use given") + Failure("Stub return value not specified for filterCoursesBySelected(fetchedCourses: [CourseForSync]). Use given") + } + return __value + } + + open func removeOldCalendar() { + addInvocation(.m_removeOldCalendar) + let perform = methodPerformValue(.m_removeOldCalendar) as? () -> Void + perform?() + } + + open func removeOutdatedEvents(courseID: String) { + addInvocation(.m_removeOutdatedEvents__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_removeOutdatedEvents__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + } + + open func syncCourse(courseID: String, courseName: String, dates: CourseDates) { + addInvocation(.m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(Parameter.value(`courseID`), Parameter.value(`courseName`), Parameter.value(`dates`))) + let perform = methodPerformValue(.m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(Parameter.value(`courseID`), Parameter.value(`courseName`), Parameter.value(`dates`))) as? (String, String, CourseDates) -> Void + perform?(`courseID`, `courseName`, `dates`) + } + + open func requestAccess() -> Bool { + addInvocation(.m_requestAccess) + let perform = methodPerformValue(.m_requestAccess) as? () -> Void + perform?() + var __value: Bool + do { + __value = try methodReturnValue(.m_requestAccess).casted() + } catch { + onFatalFailure("Stub return value not specified for requestAccess(). Use given") + Failure("Stub return value not specified for requestAccess(). Use given") + } + return __value + } + + open func courseStatus(courseID: String) -> SyncStatus { + addInvocation(.m_courseStatus__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_courseStatus__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + var __value: SyncStatus + do { + __value = try methodReturnValue(.m_courseStatus__courseID_courseID(Parameter.value(`courseID`))).casted() + } catch { + onFatalFailure("Stub return value not specified for courseStatus(courseID: String). Use given") + Failure("Stub return value not specified for courseStatus(courseID: String). Use given") + } + return __value + } + + open func clearAllData(removeCalendar: Bool) { + addInvocation(.m_clearAllData__removeCalendar_removeCalendar(Parameter.value(`removeCalendar`))) + let perform = methodPerformValue(.m_clearAllData__removeCalendar_removeCalendar(Parameter.value(`removeCalendar`))) as? (Bool) -> Void + perform?(`removeCalendar`) + } + + open func isDatesChanged(courseID: String, checksum: String) -> Bool { + addInvocation(.m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter.value(`courseID`), Parameter.value(`checksum`))) + let perform = methodPerformValue(.m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter.value(`courseID`), Parameter.value(`checksum`))) as? (String, String) -> Void + perform?(`courseID`, `checksum`) + var __value: Bool + do { + __value = try methodReturnValue(.m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter.value(`courseID`), Parameter.value(`checksum`))).casted() + } catch { + onFatalFailure("Stub return value not specified for isDatesChanged(courseID: String, checksum: String). Use given") + Failure("Stub return value not specified for isDatesChanged(courseID: String, checksum: String). Use given") + } + return __value + } + + + fileprivate enum MethodType { + case m_createCalendarIfNeeded + case m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>) + case m_removeOldCalendar + case m_removeOutdatedEvents__courseID_courseID(Parameter) + case m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(Parameter, Parameter, Parameter) + case m_requestAccess + case m_courseStatus__courseID_courseID(Parameter) + case m_clearAllData__removeCalendar_removeCalendar(Parameter) + case m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_createCalendarIfNeeded, .m_createCalendarIfNeeded): return .match + + case (.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(let lhsFetchedcourses), .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(let rhsFetchedcourses)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsFetchedcourses, rhs: rhsFetchedcourses, with: matcher), lhsFetchedcourses, rhsFetchedcourses, "fetchedCourses")) + return Matcher.ComparisonResult(results) + + case (.m_removeOldCalendar, .m_removeOldCalendar): return .match + + case (.m_removeOutdatedEvents__courseID_courseID(let lhsCourseid), .m_removeOutdatedEvents__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_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(let lhsCourseid, let lhsCoursename, let lhsDates), .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(let rhsCourseid, let rhsCoursename, let rhsDates)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDates, rhs: rhsDates, with: matcher), lhsDates, rhsDates, "dates")) + return Matcher.ComparisonResult(results) + + case (.m_requestAccess, .m_requestAccess): return .match + + case (.m_courseStatus__courseID_courseID(let lhsCourseid), .m_courseStatus__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_clearAllData__removeCalendar_removeCalendar(let lhsRemovecalendar), .m_clearAllData__removeCalendar_removeCalendar(let rhsRemovecalendar)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRemovecalendar, rhs: rhsRemovecalendar, with: matcher), lhsRemovecalendar, rhsRemovecalendar, "removeCalendar")) + return Matcher.ComparisonResult(results) + + case (.m_isDatesChanged__courseID_courseIDchecksum_checksum(let lhsCourseid, let lhsChecksum), .m_isDatesChanged__courseID_courseIDchecksum_checksum(let rhsCourseid, let rhsChecksum)): + 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: lhsChecksum, rhs: rhsChecksum, with: matcher), lhsChecksum, rhsChecksum, "checksum")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .m_createCalendarIfNeeded: return 0 + case let .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(p0): return p0.intValue + case .m_removeOldCalendar: return 0 + case let .m_removeOutdatedEvents__courseID_courseID(p0): return p0.intValue + case let .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case .m_requestAccess: return 0 + case let .m_courseStatus__courseID_courseID(p0): return p0.intValue + case let .m_clearAllData__removeCalendar_removeCalendar(p0): return p0.intValue + case let .m_isDatesChanged__courseID_courseIDchecksum_checksum(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_createCalendarIfNeeded: return ".createCalendarIfNeeded()" + case .m_filterCoursesBySelected__fetchedCourses_fetchedCourses: return ".filterCoursesBySelected(fetchedCourses:)" + case .m_removeOldCalendar: return ".removeOldCalendar()" + case .m_removeOutdatedEvents__courseID_courseID: return ".removeOutdatedEvents(courseID:)" + case .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates: return ".syncCourse(courseID:courseName:dates:)" + case .m_requestAccess: return ".requestAccess()" + case .m_courseStatus__courseID_courseID: return ".courseStatus(courseID:)" + case .m_clearAllData__removeCalendar_removeCalendar: return ".clearAllData(removeCalendar:)" + case .m_isDatesChanged__courseID_courseIDchecksum_checksum: return ".isDatesChanged(courseID:checksum:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>, willReturn: [CourseForSync]...) -> MethodStub { + return Given(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func requestAccess(willReturn: Bool...) -> MethodStub { + return Given(method: .m_requestAccess, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func courseStatus(courseID: Parameter, willReturn: SyncStatus...) -> MethodStub { + return Given(method: .m_courseStatus__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func isDatesChanged(courseID: Parameter, checksum: Parameter, willReturn: Bool...) -> MethodStub { + return Given(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>, willProduce: (Stubber<[CourseForSync]>) -> Void) -> MethodStub { + let willReturn: [[CourseForSync]] = [] + let given: Given = { return Given(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseForSync]).self) + willProduce(stubber) + return given + } + public static func requestAccess(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Bool] = [] + let given: Given = { return Given(method: .m_requestAccess, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Bool).self) + willProduce(stubber) + return given + } + public static func courseStatus(courseID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [SyncStatus] = [] + let given: Given = { return Given(method: .m_courseStatus__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (SyncStatus).self) + willProduce(stubber) + return given + } + public static func isDatesChanged(courseID: Parameter, checksum: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Bool] = [] + let given: Given = { return Given(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Bool).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func createCalendarIfNeeded() -> Verify { return Verify(method: .m_createCalendarIfNeeded)} + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>) -> Verify { return Verify(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`))} + public static func removeOldCalendar() -> Verify { return Verify(method: .m_removeOldCalendar)} + public static func removeOutdatedEvents(courseID: Parameter) -> Verify { return Verify(method: .m_removeOutdatedEvents__courseID_courseID(`courseID`))} + public static func syncCourse(courseID: Parameter, courseName: Parameter, dates: Parameter) -> Verify { return Verify(method: .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(`courseID`, `courseName`, `dates`))} + public static func requestAccess() -> Verify { return Verify(method: .m_requestAccess)} + public static func courseStatus(courseID: Parameter) -> Verify { return Verify(method: .m_courseStatus__courseID_courseID(`courseID`))} + public static func clearAllData(removeCalendar: Parameter) -> Verify { return Verify(method: .m_clearAllData__removeCalendar_removeCalendar(`removeCalendar`))} + public static func isDatesChanged(courseID: Parameter, checksum: Parameter) -> Verify { return Verify(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func createCalendarIfNeeded(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_createCalendarIfNeeded, performs: perform) + } + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>, perform: @escaping ([CourseForSync]) -> Void) -> Perform { + return Perform(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`), performs: perform) + } + public static func removeOldCalendar(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_removeOldCalendar, performs: perform) + } + public static func removeOutdatedEvents(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_removeOutdatedEvents__courseID_courseID(`courseID`), performs: perform) + } + public static func syncCourse(courseID: Parameter, courseName: Parameter, dates: Parameter, perform: @escaping (String, String, CourseDates) -> Void) -> Perform { + return Perform(method: .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(`courseID`, `courseName`, `dates`), performs: perform) + } + public static func requestAccess(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_requestAccess, performs: perform) + } + public static func courseStatus(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_courseStatus__courseID_courseID(`courseID`), performs: perform) + } + public static func clearAllData(removeCalendar: Parameter, perform: @escaping (Bool) -> Void) -> Perform { + return Perform(method: .m_clearAllData__removeCalendar_removeCalendar(`removeCalendar`), performs: perform) + } + public static func isDatesChanged(courseID: Parameter, checksum: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`), 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: - ConfigProtocol + +open class ConfigProtocolMock: ConfigProtocol, 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 baseURL: URL { + get { invocations.append(.p_baseURL_get); return __p_baseURL ?? givenGetterValue(.p_baseURL_get, "ConfigProtocolMock - stub value for baseURL was not defined") } + } + private var __p_baseURL: (URL)? + + public var baseSSOURL: URL { + get { invocations.append(.p_baseSSOURL_get); return __p_baseSSOURL ?? givenGetterValue(.p_baseSSOURL_get, "ConfigProtocolMock - stub value for baseSSOURL was not defined") } + } + private var __p_baseSSOURL: (URL)? + + public var ssoFinishedURL: URL { + get { invocations.append(.p_ssoFinishedURL_get); return __p_ssoFinishedURL ?? givenGetterValue(.p_ssoFinishedURL_get, "ConfigProtocolMock - stub value for ssoFinishedURL was not defined") } + } + private var __p_ssoFinishedURL: (URL)? + + public var ssoButtonTitle: [String: Any] { + get { invocations.append(.p_ssoButtonTitle_get); return __p_ssoButtonTitle ?? givenGetterValue(.p_ssoButtonTitle_get, "ConfigProtocolMock - stub value for ssoButtonTitle was not defined") } + } + private var __p_ssoButtonTitle: ([String: Any])? + + public var oAuthClientId: String { + get { invocations.append(.p_oAuthClientId_get); return __p_oAuthClientId ?? givenGetterValue(.p_oAuthClientId_get, "ConfigProtocolMock - stub value for oAuthClientId was not defined") } + } + private var __p_oAuthClientId: (String)? + + public var tokenType: TokenType { + get { invocations.append(.p_tokenType_get); return __p_tokenType ?? givenGetterValue(.p_tokenType_get, "ConfigProtocolMock - stub value for tokenType was not defined") } + } + private var __p_tokenType: (TokenType)? + + public var feedbackEmail: String { + get { invocations.append(.p_feedbackEmail_get); return __p_feedbackEmail ?? givenGetterValue(.p_feedbackEmail_get, "ConfigProtocolMock - stub value for feedbackEmail was not defined") } + } + private var __p_feedbackEmail: (String)? + + public var appStoreLink: String { + get { invocations.append(.p_appStoreLink_get); return __p_appStoreLink ?? givenGetterValue(.p_appStoreLink_get, "ConfigProtocolMock - stub value for appStoreLink was not defined") } + } + private var __p_appStoreLink: (String)? + + public var faq: URL? { + get { invocations.append(.p_faq_get); return __p_faq ?? optionalGivenGetterValue(.p_faq_get, "ConfigProtocolMock - stub value for faq was not defined") } + } + private var __p_faq: (URL)? + + public var platformName: String { + get { invocations.append(.p_platformName_get); return __p_platformName ?? givenGetterValue(.p_platformName_get, "ConfigProtocolMock - stub value for platformName was not defined") } + } + private var __p_platformName: (String)? + + public var agreement: AgreementConfig { + get { invocations.append(.p_agreement_get); return __p_agreement ?? givenGetterValue(.p_agreement_get, "ConfigProtocolMock - stub value for agreement was not defined") } + } + private var __p_agreement: (AgreementConfig)? + + public var firebase: FirebaseConfig { + get { invocations.append(.p_firebase_get); return __p_firebase ?? givenGetterValue(.p_firebase_get, "ConfigProtocolMock - stub value for firebase was not defined") } + } + private var __p_firebase: (FirebaseConfig)? + + public var facebook: FacebookConfig { + get { invocations.append(.p_facebook_get); return __p_facebook ?? givenGetterValue(.p_facebook_get, "ConfigProtocolMock - stub value for facebook was not defined") } + } + private var __p_facebook: (FacebookConfig)? + + public var microsoft: MicrosoftConfig { + get { invocations.append(.p_microsoft_get); return __p_microsoft ?? givenGetterValue(.p_microsoft_get, "ConfigProtocolMock - stub value for microsoft was not defined") } + } + private var __p_microsoft: (MicrosoftConfig)? + + public var google: GoogleConfig { + get { invocations.append(.p_google_get); return __p_google ?? givenGetterValue(.p_google_get, "ConfigProtocolMock - stub value for google was not defined") } + } + private var __p_google: (GoogleConfig)? + + public var appleSignIn: AppleSignInConfig { + get { invocations.append(.p_appleSignIn_get); return __p_appleSignIn ?? givenGetterValue(.p_appleSignIn_get, "ConfigProtocolMock - stub value for appleSignIn was not defined") } + } + private var __p_appleSignIn: (AppleSignInConfig)? + + public var features: FeaturesConfig { + get { invocations.append(.p_features_get); return __p_features ?? givenGetterValue(.p_features_get, "ConfigProtocolMock - stub value for features was not defined") } + } + private var __p_features: (FeaturesConfig)? + + public var theme: ThemeConfig { + get { invocations.append(.p_theme_get); return __p_theme ?? givenGetterValue(.p_theme_get, "ConfigProtocolMock - stub value for theme was not defined") } + } + private var __p_theme: (ThemeConfig)? + + public var uiComponents: UIComponentsConfig { + get { invocations.append(.p_uiComponents_get); return __p_uiComponents ?? givenGetterValue(.p_uiComponents_get, "ConfigProtocolMock - stub value for uiComponents was not defined") } + } + private var __p_uiComponents: (UIComponentsConfig)? + + public var discovery: DiscoveryConfig { + get { invocations.append(.p_discovery_get); return __p_discovery ?? givenGetterValue(.p_discovery_get, "ConfigProtocolMock - stub value for discovery was not defined") } + } + private var __p_discovery: (DiscoveryConfig)? + + public var dashboard: DashboardConfig { + get { invocations.append(.p_dashboard_get); return __p_dashboard ?? givenGetterValue(.p_dashboard_get, "ConfigProtocolMock - stub value for dashboard was not defined") } + } + private var __p_dashboard: (DashboardConfig)? + + public var braze: BrazeConfig { + get { invocations.append(.p_braze_get); return __p_braze ?? givenGetterValue(.p_braze_get, "ConfigProtocolMock - stub value for braze was not defined") } + } + private var __p_braze: (BrazeConfig)? + + public var branch: BranchConfig { + get { invocations.append(.p_branch_get); return __p_branch ?? givenGetterValue(.p_branch_get, "ConfigProtocolMock - stub value for branch was not defined") } + } + private var __p_branch: (BranchConfig)? + + public var segment: SegmentConfig { + get { invocations.append(.p_segment_get); return __p_segment ?? givenGetterValue(.p_segment_get, "ConfigProtocolMock - stub value for segment was not defined") } + } + private var __p_segment: (SegmentConfig)? + + public var program: DiscoveryConfig { + get { invocations.append(.p_program_get); return __p_program ?? givenGetterValue(.p_program_get, "ConfigProtocolMock - stub value for program was not defined") } + } + private var __p_program: (DiscoveryConfig)? + + public var URIScheme: String { + get { invocations.append(.p_URIScheme_get); return __p_URIScheme ?? givenGetterValue(.p_URIScheme_get, "ConfigProtocolMock - stub value for URIScheme was not defined") } + } + private var __p_URIScheme: (String)? + + public var fullStory: FullStoryConfig { + get { invocations.append(.p_fullStory_get); return __p_fullStory ?? givenGetterValue(.p_fullStory_get, "ConfigProtocolMock - stub value for fullStory was not defined") } + } + private var __p_fullStory: (FullStoryConfig)? + + + + + + + fileprivate enum MethodType { + case p_baseURL_get + case p_baseSSOURL_get + case p_ssoFinishedURL_get + case p_ssoButtonTitle_get + case p_oAuthClientId_get + case p_tokenType_get + case p_feedbackEmail_get + case p_appStoreLink_get + case p_faq_get + case p_platformName_get + case p_agreement_get + case p_firebase_get + case p_facebook_get + case p_microsoft_get + case p_google_get + case p_appleSignIn_get + case p_features_get + case p_theme_get + case p_uiComponents_get + case p_discovery_get + case p_dashboard_get + case p_braze_get + case p_branch_get + case p_segment_get + case p_program_get + case p_URIScheme_get + case p_fullStory_get + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { case (.p_baseURL_get,.p_baseURL_get): return Matcher.ComparisonResult.match + case (.p_baseSSOURL_get,.p_baseSSOURL_get): return Matcher.ComparisonResult.match + case (.p_ssoFinishedURL_get,.p_ssoFinishedURL_get): return Matcher.ComparisonResult.match + case (.p_ssoButtonTitle_get,.p_ssoButtonTitle_get): return Matcher.ComparisonResult.match + case (.p_oAuthClientId_get,.p_oAuthClientId_get): return Matcher.ComparisonResult.match + case (.p_tokenType_get,.p_tokenType_get): return Matcher.ComparisonResult.match + case (.p_feedbackEmail_get,.p_feedbackEmail_get): return Matcher.ComparisonResult.match + case (.p_appStoreLink_get,.p_appStoreLink_get): return Matcher.ComparisonResult.match + case (.p_faq_get,.p_faq_get): return Matcher.ComparisonResult.match + case (.p_platformName_get,.p_platformName_get): return Matcher.ComparisonResult.match + case (.p_agreement_get,.p_agreement_get): return Matcher.ComparisonResult.match + case (.p_firebase_get,.p_firebase_get): return Matcher.ComparisonResult.match + case (.p_facebook_get,.p_facebook_get): return Matcher.ComparisonResult.match + case (.p_microsoft_get,.p_microsoft_get): return Matcher.ComparisonResult.match + case (.p_google_get,.p_google_get): return Matcher.ComparisonResult.match + case (.p_appleSignIn_get,.p_appleSignIn_get): return Matcher.ComparisonResult.match + case (.p_features_get,.p_features_get): return Matcher.ComparisonResult.match + case (.p_theme_get,.p_theme_get): return Matcher.ComparisonResult.match + case (.p_uiComponents_get,.p_uiComponents_get): return Matcher.ComparisonResult.match + case (.p_discovery_get,.p_discovery_get): return Matcher.ComparisonResult.match + case (.p_dashboard_get,.p_dashboard_get): return Matcher.ComparisonResult.match + case (.p_braze_get,.p_braze_get): return Matcher.ComparisonResult.match + case (.p_branch_get,.p_branch_get): return Matcher.ComparisonResult.match + case (.p_segment_get,.p_segment_get): return Matcher.ComparisonResult.match + case (.p_program_get,.p_program_get): return Matcher.ComparisonResult.match + case (.p_URIScheme_get,.p_URIScheme_get): return Matcher.ComparisonResult.match + case (.p_fullStory_get,.p_fullStory_get): return Matcher.ComparisonResult.match + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .p_baseURL_get: return 0 + case .p_baseSSOURL_get: return 0 + case .p_ssoFinishedURL_get: return 0 + case .p_ssoButtonTitle_get: return 0 + case .p_oAuthClientId_get: return 0 + case .p_tokenType_get: return 0 + case .p_feedbackEmail_get: return 0 + case .p_appStoreLink_get: return 0 + case .p_faq_get: return 0 + case .p_platformName_get: return 0 + case .p_agreement_get: return 0 + case .p_firebase_get: return 0 + case .p_facebook_get: return 0 + case .p_microsoft_get: return 0 + case .p_google_get: return 0 + case .p_appleSignIn_get: return 0 + case .p_features_get: return 0 + case .p_theme_get: return 0 + case .p_uiComponents_get: return 0 + case .p_discovery_get: return 0 + case .p_dashboard_get: return 0 + case .p_braze_get: return 0 + case .p_branch_get: return 0 + case .p_segment_get: return 0 + case .p_program_get: return 0 + case .p_URIScheme_get: return 0 + case .p_fullStory_get: return 0 + } + } + func assertionName() -> String { + switch self { + case .p_baseURL_get: return "[get] .baseURL" + case .p_baseSSOURL_get: return "[get] .baseSSOURL" + case .p_ssoFinishedURL_get: return "[get] .ssoFinishedURL" + case .p_ssoButtonTitle_get: return "[get] .ssoButtonTitle" + case .p_oAuthClientId_get: return "[get] .oAuthClientId" + case .p_tokenType_get: return "[get] .tokenType" + case .p_feedbackEmail_get: return "[get] .feedbackEmail" + case .p_appStoreLink_get: return "[get] .appStoreLink" + case .p_faq_get: return "[get] .faq" + case .p_platformName_get: return "[get] .platformName" + case .p_agreement_get: return "[get] .agreement" + case .p_firebase_get: return "[get] .firebase" + case .p_facebook_get: return "[get] .facebook" + case .p_microsoft_get: return "[get] .microsoft" + case .p_google_get: return "[get] .google" + case .p_appleSignIn_get: return "[get] .appleSignIn" + case .p_features_get: return "[get] .features" + case .p_theme_get: return "[get] .theme" + case .p_uiComponents_get: return "[get] .uiComponents" + case .p_discovery_get: return "[get] .discovery" + case .p_dashboard_get: return "[get] .dashboard" + case .p_braze_get: return "[get] .braze" + case .p_branch_get: return "[get] .branch" + case .p_segment_get: return "[get] .segment" + case .p_program_get: return "[get] .program" + case .p_URIScheme_get: return "[get] .URIScheme" + case .p_fullStory_get: return "[get] .fullStory" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func baseURL(getter defaultValue: URL...) -> PropertyStub { + return Given(method: .p_baseURL_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func baseSSOURL(getter defaultValue: URL...) -> PropertyStub { + return Given(method: .p_baseSSOURL_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func ssoFinishedURL(getter defaultValue: URL...) -> PropertyStub { + return Given(method: .p_ssoFinishedURL_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func ssoButtonTitle(getter defaultValue: [String: Any]...) -> PropertyStub { + return Given(method: .p_ssoButtonTitle_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func oAuthClientId(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_oAuthClientId_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func tokenType(getter defaultValue: TokenType...) -> PropertyStub { + return Given(method: .p_tokenType_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func feedbackEmail(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_feedbackEmail_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appStoreLink(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_appStoreLink_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func faq(getter defaultValue: URL?...) -> PropertyStub { + return Given(method: .p_faq_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func platformName(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_platformName_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func agreement(getter defaultValue: AgreementConfig...) -> PropertyStub { + return Given(method: .p_agreement_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func firebase(getter defaultValue: FirebaseConfig...) -> PropertyStub { + return Given(method: .p_firebase_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func facebook(getter defaultValue: FacebookConfig...) -> PropertyStub { + return Given(method: .p_facebook_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func microsoft(getter defaultValue: MicrosoftConfig...) -> PropertyStub { + return Given(method: .p_microsoft_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func google(getter defaultValue: GoogleConfig...) -> PropertyStub { + return Given(method: .p_google_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appleSignIn(getter defaultValue: AppleSignInConfig...) -> PropertyStub { + return Given(method: .p_appleSignIn_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func features(getter defaultValue: FeaturesConfig...) -> PropertyStub { + return Given(method: .p_features_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func theme(getter defaultValue: ThemeConfig...) -> PropertyStub { + return Given(method: .p_theme_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func uiComponents(getter defaultValue: UIComponentsConfig...) -> PropertyStub { + return Given(method: .p_uiComponents_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func discovery(getter defaultValue: DiscoveryConfig...) -> PropertyStub { + return Given(method: .p_discovery_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func dashboard(getter defaultValue: DashboardConfig...) -> PropertyStub { + return Given(method: .p_dashboard_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func braze(getter defaultValue: BrazeConfig...) -> PropertyStub { + return Given(method: .p_braze_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func branch(getter defaultValue: BranchConfig...) -> PropertyStub { + return Given(method: .p_branch_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func segment(getter defaultValue: SegmentConfig...) -> PropertyStub { + return Given(method: .p_segment_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func program(getter defaultValue: DiscoveryConfig...) -> PropertyStub { + return Given(method: .p_program_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func URIScheme(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_URIScheme_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func fullStory(getter defaultValue: FullStoryConfig...) -> PropertyStub { + return Given(method: .p_fullStory_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static var baseURL: Verify { return Verify(method: .p_baseURL_get) } + public static var baseSSOURL: Verify { return Verify(method: .p_baseSSOURL_get) } + public static var ssoFinishedURL: Verify { return Verify(method: .p_ssoFinishedURL_get) } + public static var ssoButtonTitle: Verify { return Verify(method: .p_ssoButtonTitle_get) } + public static var oAuthClientId: Verify { return Verify(method: .p_oAuthClientId_get) } + public static var tokenType: Verify { return Verify(method: .p_tokenType_get) } + public static var feedbackEmail: Verify { return Verify(method: .p_feedbackEmail_get) } + public static var appStoreLink: Verify { return Verify(method: .p_appStoreLink_get) } + public static var faq: Verify { return Verify(method: .p_faq_get) } + public static var platformName: Verify { return Verify(method: .p_platformName_get) } + public static var agreement: Verify { return Verify(method: .p_agreement_get) } + public static var firebase: Verify { return Verify(method: .p_firebase_get) } + public static var facebook: Verify { return Verify(method: .p_facebook_get) } + public static var microsoft: Verify { return Verify(method: .p_microsoft_get) } + public static var google: Verify { return Verify(method: .p_google_get) } + public static var appleSignIn: Verify { return Verify(method: .p_appleSignIn_get) } + public static var features: Verify { return Verify(method: .p_features_get) } + public static var theme: Verify { return Verify(method: .p_theme_get) } + public static var uiComponents: Verify { return Verify(method: .p_uiComponents_get) } + public static var discovery: Verify { return Verify(method: .p_discovery_get) } + public static var dashboard: Verify { return Verify(method: .p_dashboard_get) } + public static var braze: Verify { return Verify(method: .p_braze_get) } + public static var branch: Verify { return Verify(method: .p_branch_get) } + public static var segment: Verify { return Verify(method: .p_segment_get) } + public static var program: Verify { return Verify(method: .p_program_get) } + public static var URIScheme: Verify { return Verify(method: .p_URIScheme_get) } + public static var fullStory: Verify { return Verify(method: .p_fullStory_get) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + } + + 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: - ConnectivityProtocol open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { diff --git a/Dashboard/Dashboard.xcodeproj/project.pbxproj b/Dashboard/Dashboard.xcodeproj/project.pbxproj index 625c0d2f0..d6aa32efb 100644 --- a/Dashboard/Dashboard.xcodeproj/project.pbxproj +++ b/Dashboard/Dashboard.xcodeproj/project.pbxproj @@ -36,6 +36,8 @@ 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 */; }; + CE1735062CD2552A00F9606A /* PrimaryCourseDashboardViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1735052CD2552A00F9606A /* PrimaryCourseDashboardViewModelTests.swift */; }; + CE17350A2CD26CB500F9606A /* AllCoursesViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1735092CD26CB500F9606A /* AllCoursesViewModelTests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -91,6 +93,8 @@ 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 = ""; }; + CE1735052CD2552A00F9606A /* PrimaryCourseDashboardViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PrimaryCourseDashboardViewModelTests.swift; sourceTree = ""; }; + CE1735092CD26CB500F9606A /* AllCoursesViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AllCoursesViewModelTests.swift; 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 = ""; }; E36D702D7E3F9A8B3303AD0A /* Pods-App-Dashboard-DashboardTests.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Dashboard-DashboardTests.releasedev.xcconfig"; path = "Target Support Files/Pods-App-Dashboard-DashboardTests/Pods-App-Dashboard-DashboardTests.releasedev.xcconfig"; sourceTree = ""; }; E5B672C28C8F9279BB4E5C9B /* Pods-App-Dashboard.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Dashboard.releasestage.xcconfig"; path = "Target Support Files/Pods-App-Dashboard/Pods-App-Dashboard.releasestage.xcconfig"; sourceTree = ""; }; @@ -237,6 +241,8 @@ 0766DFD2299AD99B00EBEF6A /* Presentation */ = { isa = PBXGroup; children = ( + CE1735092CD26CB500F9606A /* AllCoursesViewModelTests.swift */, + CE1735052CD2552A00F9606A /* PrimaryCourseDashboardViewModelTests.swift */, 02A9A90A2978194100B55797 /* DashboardViewModelTests.swift */, ); path = Presentation; @@ -483,7 +489,9 @@ buildActionMask = 2147483647; files = ( 02A9A90B2978194100B55797 /* DashboardViewModelTests.swift in Sources */, + CE17350A2CD26CB500F9606A /* AllCoursesViewModelTests.swift in Sources */, 02A9A92929781A4D00B55797 /* DashboardMock.generated.swift in Sources */, + CE1735062CD2552A00F9606A /* PrimaryCourseDashboardViewModelTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index a63173694..f0dcb32d9 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -978,6 +978,899 @@ open class BaseRouterMock: BaseRouter, Mock { } } +// MARK: - CalendarManagerProtocol + +open class CalendarManagerProtocolMock: CalendarManagerProtocol, 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 createCalendarIfNeeded() { + addInvocation(.m_createCalendarIfNeeded) + let perform = methodPerformValue(.m_createCalendarIfNeeded) as? () -> Void + perform?() + } + + open func filterCoursesBySelected(fetchedCourses: [CourseForSync]) -> [CourseForSync] { + addInvocation(.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>.value(`fetchedCourses`))) + let perform = methodPerformValue(.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>.value(`fetchedCourses`))) as? ([CourseForSync]) -> Void + perform?(`fetchedCourses`) + var __value: [CourseForSync] + do { + __value = try methodReturnValue(.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>.value(`fetchedCourses`))).casted() + } catch { + onFatalFailure("Stub return value not specified for filterCoursesBySelected(fetchedCourses: [CourseForSync]). Use given") + Failure("Stub return value not specified for filterCoursesBySelected(fetchedCourses: [CourseForSync]). Use given") + } + return __value + } + + open func removeOldCalendar() { + addInvocation(.m_removeOldCalendar) + let perform = methodPerformValue(.m_removeOldCalendar) as? () -> Void + perform?() + } + + open func removeOutdatedEvents(courseID: String) { + addInvocation(.m_removeOutdatedEvents__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_removeOutdatedEvents__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + } + + open func syncCourse(courseID: String, courseName: String, dates: CourseDates) { + addInvocation(.m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(Parameter.value(`courseID`), Parameter.value(`courseName`), Parameter.value(`dates`))) + let perform = methodPerformValue(.m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(Parameter.value(`courseID`), Parameter.value(`courseName`), Parameter.value(`dates`))) as? (String, String, CourseDates) -> Void + perform?(`courseID`, `courseName`, `dates`) + } + + open func requestAccess() -> Bool { + addInvocation(.m_requestAccess) + let perform = methodPerformValue(.m_requestAccess) as? () -> Void + perform?() + var __value: Bool + do { + __value = try methodReturnValue(.m_requestAccess).casted() + } catch { + onFatalFailure("Stub return value not specified for requestAccess(). Use given") + Failure("Stub return value not specified for requestAccess(). Use given") + } + return __value + } + + open func courseStatus(courseID: String) -> SyncStatus { + addInvocation(.m_courseStatus__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_courseStatus__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + var __value: SyncStatus + do { + __value = try methodReturnValue(.m_courseStatus__courseID_courseID(Parameter.value(`courseID`))).casted() + } catch { + onFatalFailure("Stub return value not specified for courseStatus(courseID: String). Use given") + Failure("Stub return value not specified for courseStatus(courseID: String). Use given") + } + return __value + } + + open func clearAllData(removeCalendar: Bool) { + addInvocation(.m_clearAllData__removeCalendar_removeCalendar(Parameter.value(`removeCalendar`))) + let perform = methodPerformValue(.m_clearAllData__removeCalendar_removeCalendar(Parameter.value(`removeCalendar`))) as? (Bool) -> Void + perform?(`removeCalendar`) + } + + open func isDatesChanged(courseID: String, checksum: String) -> Bool { + addInvocation(.m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter.value(`courseID`), Parameter.value(`checksum`))) + let perform = methodPerformValue(.m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter.value(`courseID`), Parameter.value(`checksum`))) as? (String, String) -> Void + perform?(`courseID`, `checksum`) + var __value: Bool + do { + __value = try methodReturnValue(.m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter.value(`courseID`), Parameter.value(`checksum`))).casted() + } catch { + onFatalFailure("Stub return value not specified for isDatesChanged(courseID: String, checksum: String). Use given") + Failure("Stub return value not specified for isDatesChanged(courseID: String, checksum: String). Use given") + } + return __value + } + + + fileprivate enum MethodType { + case m_createCalendarIfNeeded + case m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>) + case m_removeOldCalendar + case m_removeOutdatedEvents__courseID_courseID(Parameter) + case m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(Parameter, Parameter, Parameter) + case m_requestAccess + case m_courseStatus__courseID_courseID(Parameter) + case m_clearAllData__removeCalendar_removeCalendar(Parameter) + case m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_createCalendarIfNeeded, .m_createCalendarIfNeeded): return .match + + case (.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(let lhsFetchedcourses), .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(let rhsFetchedcourses)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsFetchedcourses, rhs: rhsFetchedcourses, with: matcher), lhsFetchedcourses, rhsFetchedcourses, "fetchedCourses")) + return Matcher.ComparisonResult(results) + + case (.m_removeOldCalendar, .m_removeOldCalendar): return .match + + case (.m_removeOutdatedEvents__courseID_courseID(let lhsCourseid), .m_removeOutdatedEvents__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_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(let lhsCourseid, let lhsCoursename, let lhsDates), .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(let rhsCourseid, let rhsCoursename, let rhsDates)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDates, rhs: rhsDates, with: matcher), lhsDates, rhsDates, "dates")) + return Matcher.ComparisonResult(results) + + case (.m_requestAccess, .m_requestAccess): return .match + + case (.m_courseStatus__courseID_courseID(let lhsCourseid), .m_courseStatus__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_clearAllData__removeCalendar_removeCalendar(let lhsRemovecalendar), .m_clearAllData__removeCalendar_removeCalendar(let rhsRemovecalendar)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRemovecalendar, rhs: rhsRemovecalendar, with: matcher), lhsRemovecalendar, rhsRemovecalendar, "removeCalendar")) + return Matcher.ComparisonResult(results) + + case (.m_isDatesChanged__courseID_courseIDchecksum_checksum(let lhsCourseid, let lhsChecksum), .m_isDatesChanged__courseID_courseIDchecksum_checksum(let rhsCourseid, let rhsChecksum)): + 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: lhsChecksum, rhs: rhsChecksum, with: matcher), lhsChecksum, rhsChecksum, "checksum")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .m_createCalendarIfNeeded: return 0 + case let .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(p0): return p0.intValue + case .m_removeOldCalendar: return 0 + case let .m_removeOutdatedEvents__courseID_courseID(p0): return p0.intValue + case let .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case .m_requestAccess: return 0 + case let .m_courseStatus__courseID_courseID(p0): return p0.intValue + case let .m_clearAllData__removeCalendar_removeCalendar(p0): return p0.intValue + case let .m_isDatesChanged__courseID_courseIDchecksum_checksum(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_createCalendarIfNeeded: return ".createCalendarIfNeeded()" + case .m_filterCoursesBySelected__fetchedCourses_fetchedCourses: return ".filterCoursesBySelected(fetchedCourses:)" + case .m_removeOldCalendar: return ".removeOldCalendar()" + case .m_removeOutdatedEvents__courseID_courseID: return ".removeOutdatedEvents(courseID:)" + case .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates: return ".syncCourse(courseID:courseName:dates:)" + case .m_requestAccess: return ".requestAccess()" + case .m_courseStatus__courseID_courseID: return ".courseStatus(courseID:)" + case .m_clearAllData__removeCalendar_removeCalendar: return ".clearAllData(removeCalendar:)" + case .m_isDatesChanged__courseID_courseIDchecksum_checksum: return ".isDatesChanged(courseID:checksum:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>, willReturn: [CourseForSync]...) -> MethodStub { + return Given(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func requestAccess(willReturn: Bool...) -> MethodStub { + return Given(method: .m_requestAccess, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func courseStatus(courseID: Parameter, willReturn: SyncStatus...) -> MethodStub { + return Given(method: .m_courseStatus__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func isDatesChanged(courseID: Parameter, checksum: Parameter, willReturn: Bool...) -> MethodStub { + return Given(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>, willProduce: (Stubber<[CourseForSync]>) -> Void) -> MethodStub { + let willReturn: [[CourseForSync]] = [] + let given: Given = { return Given(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseForSync]).self) + willProduce(stubber) + return given + } + public static func requestAccess(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Bool] = [] + let given: Given = { return Given(method: .m_requestAccess, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Bool).self) + willProduce(stubber) + return given + } + public static func courseStatus(courseID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [SyncStatus] = [] + let given: Given = { return Given(method: .m_courseStatus__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (SyncStatus).self) + willProduce(stubber) + return given + } + public static func isDatesChanged(courseID: Parameter, checksum: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Bool] = [] + let given: Given = { return Given(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Bool).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func createCalendarIfNeeded() -> Verify { return Verify(method: .m_createCalendarIfNeeded)} + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>) -> Verify { return Verify(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`))} + public static func removeOldCalendar() -> Verify { return Verify(method: .m_removeOldCalendar)} + public static func removeOutdatedEvents(courseID: Parameter) -> Verify { return Verify(method: .m_removeOutdatedEvents__courseID_courseID(`courseID`))} + public static func syncCourse(courseID: Parameter, courseName: Parameter, dates: Parameter) -> Verify { return Verify(method: .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(`courseID`, `courseName`, `dates`))} + public static func requestAccess() -> Verify { return Verify(method: .m_requestAccess)} + public static func courseStatus(courseID: Parameter) -> Verify { return Verify(method: .m_courseStatus__courseID_courseID(`courseID`))} + public static func clearAllData(removeCalendar: Parameter) -> Verify { return Verify(method: .m_clearAllData__removeCalendar_removeCalendar(`removeCalendar`))} + public static func isDatesChanged(courseID: Parameter, checksum: Parameter) -> Verify { return Verify(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func createCalendarIfNeeded(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_createCalendarIfNeeded, performs: perform) + } + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>, perform: @escaping ([CourseForSync]) -> Void) -> Perform { + return Perform(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`), performs: perform) + } + public static func removeOldCalendar(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_removeOldCalendar, performs: perform) + } + public static func removeOutdatedEvents(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_removeOutdatedEvents__courseID_courseID(`courseID`), performs: perform) + } + public static func syncCourse(courseID: Parameter, courseName: Parameter, dates: Parameter, perform: @escaping (String, String, CourseDates) -> Void) -> Perform { + return Perform(method: .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(`courseID`, `courseName`, `dates`), performs: perform) + } + public static func requestAccess(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_requestAccess, performs: perform) + } + public static func courseStatus(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_courseStatus__courseID_courseID(`courseID`), performs: perform) + } + public static func clearAllData(removeCalendar: Parameter, perform: @escaping (Bool) -> Void) -> Perform { + return Perform(method: .m_clearAllData__removeCalendar_removeCalendar(`removeCalendar`), performs: perform) + } + public static func isDatesChanged(courseID: Parameter, checksum: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`), 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: - ConfigProtocol + +open class ConfigProtocolMock: ConfigProtocol, 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 baseURL: URL { + get { invocations.append(.p_baseURL_get); return __p_baseURL ?? givenGetterValue(.p_baseURL_get, "ConfigProtocolMock - stub value for baseURL was not defined") } + } + private var __p_baseURL: (URL)? + + public var baseSSOURL: URL { + get { invocations.append(.p_baseSSOURL_get); return __p_baseSSOURL ?? givenGetterValue(.p_baseSSOURL_get, "ConfigProtocolMock - stub value for baseSSOURL was not defined") } + } + private var __p_baseSSOURL: (URL)? + + public var ssoFinishedURL: URL { + get { invocations.append(.p_ssoFinishedURL_get); return __p_ssoFinishedURL ?? givenGetterValue(.p_ssoFinishedURL_get, "ConfigProtocolMock - stub value for ssoFinishedURL was not defined") } + } + private var __p_ssoFinishedURL: (URL)? + + public var ssoButtonTitle: [String: Any] { + get { invocations.append(.p_ssoButtonTitle_get); return __p_ssoButtonTitle ?? givenGetterValue(.p_ssoButtonTitle_get, "ConfigProtocolMock - stub value for ssoButtonTitle was not defined") } + } + private var __p_ssoButtonTitle: ([String: Any])? + + public var oAuthClientId: String { + get { invocations.append(.p_oAuthClientId_get); return __p_oAuthClientId ?? givenGetterValue(.p_oAuthClientId_get, "ConfigProtocolMock - stub value for oAuthClientId was not defined") } + } + private var __p_oAuthClientId: (String)? + + public var tokenType: TokenType { + get { invocations.append(.p_tokenType_get); return __p_tokenType ?? givenGetterValue(.p_tokenType_get, "ConfigProtocolMock - stub value for tokenType was not defined") } + } + private var __p_tokenType: (TokenType)? + + public var feedbackEmail: String { + get { invocations.append(.p_feedbackEmail_get); return __p_feedbackEmail ?? givenGetterValue(.p_feedbackEmail_get, "ConfigProtocolMock - stub value for feedbackEmail was not defined") } + } + private var __p_feedbackEmail: (String)? + + public var appStoreLink: String { + get { invocations.append(.p_appStoreLink_get); return __p_appStoreLink ?? givenGetterValue(.p_appStoreLink_get, "ConfigProtocolMock - stub value for appStoreLink was not defined") } + } + private var __p_appStoreLink: (String)? + + public var faq: URL? { + get { invocations.append(.p_faq_get); return __p_faq ?? optionalGivenGetterValue(.p_faq_get, "ConfigProtocolMock - stub value for faq was not defined") } + } + private var __p_faq: (URL)? + + public var platformName: String { + get { invocations.append(.p_platformName_get); return __p_platformName ?? givenGetterValue(.p_platformName_get, "ConfigProtocolMock - stub value for platformName was not defined") } + } + private var __p_platformName: (String)? + + public var agreement: AgreementConfig { + get { invocations.append(.p_agreement_get); return __p_agreement ?? givenGetterValue(.p_agreement_get, "ConfigProtocolMock - stub value for agreement was not defined") } + } + private var __p_agreement: (AgreementConfig)? + + public var firebase: FirebaseConfig { + get { invocations.append(.p_firebase_get); return __p_firebase ?? givenGetterValue(.p_firebase_get, "ConfigProtocolMock - stub value for firebase was not defined") } + } + private var __p_firebase: (FirebaseConfig)? + + public var facebook: FacebookConfig { + get { invocations.append(.p_facebook_get); return __p_facebook ?? givenGetterValue(.p_facebook_get, "ConfigProtocolMock - stub value for facebook was not defined") } + } + private var __p_facebook: (FacebookConfig)? + + public var microsoft: MicrosoftConfig { + get { invocations.append(.p_microsoft_get); return __p_microsoft ?? givenGetterValue(.p_microsoft_get, "ConfigProtocolMock - stub value for microsoft was not defined") } + } + private var __p_microsoft: (MicrosoftConfig)? + + public var google: GoogleConfig { + get { invocations.append(.p_google_get); return __p_google ?? givenGetterValue(.p_google_get, "ConfigProtocolMock - stub value for google was not defined") } + } + private var __p_google: (GoogleConfig)? + + public var appleSignIn: AppleSignInConfig { + get { invocations.append(.p_appleSignIn_get); return __p_appleSignIn ?? givenGetterValue(.p_appleSignIn_get, "ConfigProtocolMock - stub value for appleSignIn was not defined") } + } + private var __p_appleSignIn: (AppleSignInConfig)? + + public var features: FeaturesConfig { + get { invocations.append(.p_features_get); return __p_features ?? givenGetterValue(.p_features_get, "ConfigProtocolMock - stub value for features was not defined") } + } + private var __p_features: (FeaturesConfig)? + + public var theme: ThemeConfig { + get { invocations.append(.p_theme_get); return __p_theme ?? givenGetterValue(.p_theme_get, "ConfigProtocolMock - stub value for theme was not defined") } + } + private var __p_theme: (ThemeConfig)? + + public var uiComponents: UIComponentsConfig { + get { invocations.append(.p_uiComponents_get); return __p_uiComponents ?? givenGetterValue(.p_uiComponents_get, "ConfigProtocolMock - stub value for uiComponents was not defined") } + } + private var __p_uiComponents: (UIComponentsConfig)? + + public var discovery: DiscoveryConfig { + get { invocations.append(.p_discovery_get); return __p_discovery ?? givenGetterValue(.p_discovery_get, "ConfigProtocolMock - stub value for discovery was not defined") } + } + private var __p_discovery: (DiscoveryConfig)? + + public var dashboard: DashboardConfig { + get { invocations.append(.p_dashboard_get); return __p_dashboard ?? givenGetterValue(.p_dashboard_get, "ConfigProtocolMock - stub value for dashboard was not defined") } + } + private var __p_dashboard: (DashboardConfig)? + + public var braze: BrazeConfig { + get { invocations.append(.p_braze_get); return __p_braze ?? givenGetterValue(.p_braze_get, "ConfigProtocolMock - stub value for braze was not defined") } + } + private var __p_braze: (BrazeConfig)? + + public var branch: BranchConfig { + get { invocations.append(.p_branch_get); return __p_branch ?? givenGetterValue(.p_branch_get, "ConfigProtocolMock - stub value for branch was not defined") } + } + private var __p_branch: (BranchConfig)? + + public var segment: SegmentConfig { + get { invocations.append(.p_segment_get); return __p_segment ?? givenGetterValue(.p_segment_get, "ConfigProtocolMock - stub value for segment was not defined") } + } + private var __p_segment: (SegmentConfig)? + + public var program: DiscoveryConfig { + get { invocations.append(.p_program_get); return __p_program ?? givenGetterValue(.p_program_get, "ConfigProtocolMock - stub value for program was not defined") } + } + private var __p_program: (DiscoveryConfig)? + + public var URIScheme: String { + get { invocations.append(.p_URIScheme_get); return __p_URIScheme ?? givenGetterValue(.p_URIScheme_get, "ConfigProtocolMock - stub value for URIScheme was not defined") } + } + private var __p_URIScheme: (String)? + + public var fullStory: FullStoryConfig { + get { invocations.append(.p_fullStory_get); return __p_fullStory ?? givenGetterValue(.p_fullStory_get, "ConfigProtocolMock - stub value for fullStory was not defined") } + } + private var __p_fullStory: (FullStoryConfig)? + + + + + + + fileprivate enum MethodType { + case p_baseURL_get + case p_baseSSOURL_get + case p_ssoFinishedURL_get + case p_ssoButtonTitle_get + case p_oAuthClientId_get + case p_tokenType_get + case p_feedbackEmail_get + case p_appStoreLink_get + case p_faq_get + case p_platformName_get + case p_agreement_get + case p_firebase_get + case p_facebook_get + case p_microsoft_get + case p_google_get + case p_appleSignIn_get + case p_features_get + case p_theme_get + case p_uiComponents_get + case p_discovery_get + case p_dashboard_get + case p_braze_get + case p_branch_get + case p_segment_get + case p_program_get + case p_URIScheme_get + case p_fullStory_get + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { case (.p_baseURL_get,.p_baseURL_get): return Matcher.ComparisonResult.match + case (.p_baseSSOURL_get,.p_baseSSOURL_get): return Matcher.ComparisonResult.match + case (.p_ssoFinishedURL_get,.p_ssoFinishedURL_get): return Matcher.ComparisonResult.match + case (.p_ssoButtonTitle_get,.p_ssoButtonTitle_get): return Matcher.ComparisonResult.match + case (.p_oAuthClientId_get,.p_oAuthClientId_get): return Matcher.ComparisonResult.match + case (.p_tokenType_get,.p_tokenType_get): return Matcher.ComparisonResult.match + case (.p_feedbackEmail_get,.p_feedbackEmail_get): return Matcher.ComparisonResult.match + case (.p_appStoreLink_get,.p_appStoreLink_get): return Matcher.ComparisonResult.match + case (.p_faq_get,.p_faq_get): return Matcher.ComparisonResult.match + case (.p_platformName_get,.p_platformName_get): return Matcher.ComparisonResult.match + case (.p_agreement_get,.p_agreement_get): return Matcher.ComparisonResult.match + case (.p_firebase_get,.p_firebase_get): return Matcher.ComparisonResult.match + case (.p_facebook_get,.p_facebook_get): return Matcher.ComparisonResult.match + case (.p_microsoft_get,.p_microsoft_get): return Matcher.ComparisonResult.match + case (.p_google_get,.p_google_get): return Matcher.ComparisonResult.match + case (.p_appleSignIn_get,.p_appleSignIn_get): return Matcher.ComparisonResult.match + case (.p_features_get,.p_features_get): return Matcher.ComparisonResult.match + case (.p_theme_get,.p_theme_get): return Matcher.ComparisonResult.match + case (.p_uiComponents_get,.p_uiComponents_get): return Matcher.ComparisonResult.match + case (.p_discovery_get,.p_discovery_get): return Matcher.ComparisonResult.match + case (.p_dashboard_get,.p_dashboard_get): return Matcher.ComparisonResult.match + case (.p_braze_get,.p_braze_get): return Matcher.ComparisonResult.match + case (.p_branch_get,.p_branch_get): return Matcher.ComparisonResult.match + case (.p_segment_get,.p_segment_get): return Matcher.ComparisonResult.match + case (.p_program_get,.p_program_get): return Matcher.ComparisonResult.match + case (.p_URIScheme_get,.p_URIScheme_get): return Matcher.ComparisonResult.match + case (.p_fullStory_get,.p_fullStory_get): return Matcher.ComparisonResult.match + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .p_baseURL_get: return 0 + case .p_baseSSOURL_get: return 0 + case .p_ssoFinishedURL_get: return 0 + case .p_ssoButtonTitle_get: return 0 + case .p_oAuthClientId_get: return 0 + case .p_tokenType_get: return 0 + case .p_feedbackEmail_get: return 0 + case .p_appStoreLink_get: return 0 + case .p_faq_get: return 0 + case .p_platformName_get: return 0 + case .p_agreement_get: return 0 + case .p_firebase_get: return 0 + case .p_facebook_get: return 0 + case .p_microsoft_get: return 0 + case .p_google_get: return 0 + case .p_appleSignIn_get: return 0 + case .p_features_get: return 0 + case .p_theme_get: return 0 + case .p_uiComponents_get: return 0 + case .p_discovery_get: return 0 + case .p_dashboard_get: return 0 + case .p_braze_get: return 0 + case .p_branch_get: return 0 + case .p_segment_get: return 0 + case .p_program_get: return 0 + case .p_URIScheme_get: return 0 + case .p_fullStory_get: return 0 + } + } + func assertionName() -> String { + switch self { + case .p_baseURL_get: return "[get] .baseURL" + case .p_baseSSOURL_get: return "[get] .baseSSOURL" + case .p_ssoFinishedURL_get: return "[get] .ssoFinishedURL" + case .p_ssoButtonTitle_get: return "[get] .ssoButtonTitle" + case .p_oAuthClientId_get: return "[get] .oAuthClientId" + case .p_tokenType_get: return "[get] .tokenType" + case .p_feedbackEmail_get: return "[get] .feedbackEmail" + case .p_appStoreLink_get: return "[get] .appStoreLink" + case .p_faq_get: return "[get] .faq" + case .p_platformName_get: return "[get] .platformName" + case .p_agreement_get: return "[get] .agreement" + case .p_firebase_get: return "[get] .firebase" + case .p_facebook_get: return "[get] .facebook" + case .p_microsoft_get: return "[get] .microsoft" + case .p_google_get: return "[get] .google" + case .p_appleSignIn_get: return "[get] .appleSignIn" + case .p_features_get: return "[get] .features" + case .p_theme_get: return "[get] .theme" + case .p_uiComponents_get: return "[get] .uiComponents" + case .p_discovery_get: return "[get] .discovery" + case .p_dashboard_get: return "[get] .dashboard" + case .p_braze_get: return "[get] .braze" + case .p_branch_get: return "[get] .branch" + case .p_segment_get: return "[get] .segment" + case .p_program_get: return "[get] .program" + case .p_URIScheme_get: return "[get] .URIScheme" + case .p_fullStory_get: return "[get] .fullStory" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func baseURL(getter defaultValue: URL...) -> PropertyStub { + return Given(method: .p_baseURL_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func baseSSOURL(getter defaultValue: URL...) -> PropertyStub { + return Given(method: .p_baseSSOURL_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func ssoFinishedURL(getter defaultValue: URL...) -> PropertyStub { + return Given(method: .p_ssoFinishedURL_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func ssoButtonTitle(getter defaultValue: [String: Any]...) -> PropertyStub { + return Given(method: .p_ssoButtonTitle_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func oAuthClientId(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_oAuthClientId_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func tokenType(getter defaultValue: TokenType...) -> PropertyStub { + return Given(method: .p_tokenType_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func feedbackEmail(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_feedbackEmail_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appStoreLink(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_appStoreLink_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func faq(getter defaultValue: URL?...) -> PropertyStub { + return Given(method: .p_faq_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func platformName(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_platformName_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func agreement(getter defaultValue: AgreementConfig...) -> PropertyStub { + return Given(method: .p_agreement_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func firebase(getter defaultValue: FirebaseConfig...) -> PropertyStub { + return Given(method: .p_firebase_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func facebook(getter defaultValue: FacebookConfig...) -> PropertyStub { + return Given(method: .p_facebook_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func microsoft(getter defaultValue: MicrosoftConfig...) -> PropertyStub { + return Given(method: .p_microsoft_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func google(getter defaultValue: GoogleConfig...) -> PropertyStub { + return Given(method: .p_google_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appleSignIn(getter defaultValue: AppleSignInConfig...) -> PropertyStub { + return Given(method: .p_appleSignIn_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func features(getter defaultValue: FeaturesConfig...) -> PropertyStub { + return Given(method: .p_features_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func theme(getter defaultValue: ThemeConfig...) -> PropertyStub { + return Given(method: .p_theme_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func uiComponents(getter defaultValue: UIComponentsConfig...) -> PropertyStub { + return Given(method: .p_uiComponents_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func discovery(getter defaultValue: DiscoveryConfig...) -> PropertyStub { + return Given(method: .p_discovery_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func dashboard(getter defaultValue: DashboardConfig...) -> PropertyStub { + return Given(method: .p_dashboard_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func braze(getter defaultValue: BrazeConfig...) -> PropertyStub { + return Given(method: .p_braze_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func branch(getter defaultValue: BranchConfig...) -> PropertyStub { + return Given(method: .p_branch_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func segment(getter defaultValue: SegmentConfig...) -> PropertyStub { + return Given(method: .p_segment_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func program(getter defaultValue: DiscoveryConfig...) -> PropertyStub { + return Given(method: .p_program_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func URIScheme(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_URIScheme_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func fullStory(getter defaultValue: FullStoryConfig...) -> PropertyStub { + return Given(method: .p_fullStory_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static var baseURL: Verify { return Verify(method: .p_baseURL_get) } + public static var baseSSOURL: Verify { return Verify(method: .p_baseSSOURL_get) } + public static var ssoFinishedURL: Verify { return Verify(method: .p_ssoFinishedURL_get) } + public static var ssoButtonTitle: Verify { return Verify(method: .p_ssoButtonTitle_get) } + public static var oAuthClientId: Verify { return Verify(method: .p_oAuthClientId_get) } + public static var tokenType: Verify { return Verify(method: .p_tokenType_get) } + public static var feedbackEmail: Verify { return Verify(method: .p_feedbackEmail_get) } + public static var appStoreLink: Verify { return Verify(method: .p_appStoreLink_get) } + public static var faq: Verify { return Verify(method: .p_faq_get) } + public static var platformName: Verify { return Verify(method: .p_platformName_get) } + public static var agreement: Verify { return Verify(method: .p_agreement_get) } + public static var firebase: Verify { return Verify(method: .p_firebase_get) } + public static var facebook: Verify { return Verify(method: .p_facebook_get) } + public static var microsoft: Verify { return Verify(method: .p_microsoft_get) } + public static var google: Verify { return Verify(method: .p_google_get) } + public static var appleSignIn: Verify { return Verify(method: .p_appleSignIn_get) } + public static var features: Verify { return Verify(method: .p_features_get) } + public static var theme: Verify { return Verify(method: .p_theme_get) } + public static var uiComponents: Verify { return Verify(method: .p_uiComponents_get) } + public static var discovery: Verify { return Verify(method: .p_discovery_get) } + public static var dashboard: Verify { return Verify(method: .p_dashboard_get) } + public static var braze: Verify { return Verify(method: .p_braze_get) } + public static var branch: Verify { return Verify(method: .p_branch_get) } + public static var segment: Verify { return Verify(method: .p_segment_get) } + public static var program: Verify { return Verify(method: .p_program_get) } + public static var URIScheme: Verify { return Verify(method: .p_URIScheme_get) } + public static var fullStory: Verify { return Verify(method: .p_fullStory_get) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + } + + 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: - ConnectivityProtocol open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { diff --git a/Dashboard/DashboardTests/Presentation/AllCoursesViewModelTests.swift b/Dashboard/DashboardTests/Presentation/AllCoursesViewModelTests.swift new file mode 100644 index 000000000..625120e66 --- /dev/null +++ b/Dashboard/DashboardTests/Presentation/AllCoursesViewModelTests.swift @@ -0,0 +1,219 @@ +// +// AllCoursesViewModelTests.swift +// Dashboard +// +// Created by Ivan Stepanok on 30.10.2024. +// + + +import SwiftyMocky +import XCTest +@testable import Core +@testable import Dashboard +import Combine +import SwiftUI + +final class AllCoursesViewModelTests: XCTestCase { + + var interactor: DashboardInteractorProtocolMock! + var connectivity: ConnectivityProtocolMock! + var analytics: DashboardAnalyticsMock! + var storage: CoreStorageMock! + + override func setUp() { + super.setUp() + interactor = DashboardInteractorProtocolMock() + connectivity = ConnectivityProtocolMock() + analytics = DashboardAnalyticsMock() + storage = CoreStorageMock() + } + + let mockEnrollment = PrimaryEnrollment( + primaryCourse: PrimaryCourse.init( + name: "Primary Course", + org: "OpenEdX", + courseID: "1", + hasAccess: true, + courseStart: Date(), + courseEnd: nil, + courseBanner: "https://example.com/banner.jpg", + futureAssignments: [], + pastAssignments: [], + progressEarned: 0, + progressPossible: 1, + lastVisitedBlockID: nil, + resumeTitle: nil + ), + courses: [ + CourseItem.init( + name: "Course", + org: "OpenEdX", + shortDescription: "short description", + imageURL: "https://examlpe.com/image.jpg", + hasAccess: true, + courseStart: nil, + courseEnd: nil, + enrollmentStart: nil, + enrollmentEnd: nil, + courseID: "2", + numPages: 1, + coursesCount: 3, + progressEarned: 0, + progressPossible: 2 + ), + CourseItem.init( + name: "Course", + org: "OpenEdX", + shortDescription: "short description", + imageURL: "https://examlpe.com/image.jpg", + hasAccess: true, + courseStart: nil, + courseEnd: nil, + enrollmentStart: nil, + enrollmentEnd: nil, + courseID: "3", + numPages: 1, + coursesCount: 3, + progressEarned: 0, + progressPossible: 2 + ), + CourseItem.init( + name: "Course", + org: "OpenEdX", + shortDescription: "short description", + imageURL: "https://examlpe.com/image.jpg", + hasAccess: true, + courseStart: nil, + courseEnd: nil, + enrollmentStart: nil, + enrollmentEnd: nil, + courseID: "4", + numPages: 1, + coursesCount: 3, + progressEarned: 0, + progressPossible: 2 + ) + ], + totalPages: 2, + count: 1 + ) + + func testGetCoursesSuccess() async throws { + // Given + let viewModel = AllCoursesViewModel( + interactor: interactor, + connectivity: connectivity, + analytics: analytics, + storage: storage + ) + + Given(interactor, .getAllCourses(filteredBy: .any, page: .any, willReturn: mockEnrollment)) + + // When + await viewModel.getCourses(page: 1) + + // Then + Verify(interactor, 1, .getAllCourses(filteredBy: .any, page: .value(1))) + XCTAssertEqual(viewModel.myEnrollments?.courses.count, 3) + XCTAssertEqual(viewModel.nextPage, 2) + XCTAssertEqual(viewModel.totalPages, 2) + XCTAssertFalse(viewModel.fetchInProgress) + XCTAssertFalse(viewModel.showError) + } + + func testGetCoursesWithPagination() async throws { + // Given + let viewModel = AllCoursesViewModel( + interactor: interactor, + connectivity: connectivity, + analytics: analytics, + storage: storage + ) + + Given(interactor, .getAllCourses(filteredBy: .any, page: .any, willReturn: mockEnrollment)) + + // When + await viewModel.getCourses(page: 1) + await viewModel.getCourses(page: 2) + + // Then + Verify(interactor, 2, .getAllCourses(filteredBy: .any, page: .any)) + XCTAssertEqual(viewModel.nextPage, 3) + } + + func testGetCoursesNoCachedDataError() async throws { + // Given + let viewModel = AllCoursesViewModel( + interactor: interactor, + connectivity: connectivity, + analytics: analytics, + storage: storage + ) + + Given(interactor, .getAllCourses(filteredBy: .any, page: .any, willThrow: NoCachedDataError())) + + // When + await viewModel.getCourses(page: 1) + + // Then + XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.noCachedData) + XCTAssertTrue(viewModel.showError) + XCTAssertFalse(viewModel.fetchInProgress) + } + + func testGetCoursesUnknownError() async throws { + // Given + let viewModel = AllCoursesViewModel( + interactor: interactor, + connectivity: connectivity, + analytics: analytics, + storage: storage + ) + + Given(interactor, .getAllCourses(filteredBy: .any, page: .any, willThrow: NSError())) + + // When + await viewModel.getCourses(page: 1) + + // Then + XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) + XCTAssertTrue(viewModel.showError) + XCTAssertFalse(viewModel.fetchInProgress) + } + + func testGetMyCoursesPagination() async { + // Given + let viewModel = AllCoursesViewModel( + interactor: interactor, + connectivity: connectivity, + analytics: analytics, + storage: storage + ) + + Given(interactor, .getAllCourses(filteredBy: .any, page: .any, willReturn: mockEnrollment)) + + // When + await viewModel.getCourses(page: 1) + await viewModel.getMyCoursesPagination(index: 0) + await viewModel.getMyCoursesPagination(index: mockEnrollment.courses.count - 3) + + // Then + Verify(interactor, 2, .getAllCourses(filteredBy: .any, page: .any)) + } + + func testTrackDashboardCourseClicked() { + // Given + let viewModel = AllCoursesViewModel( + interactor: interactor, + connectivity: connectivity, + analytics: analytics, + storage: storage + ) + + // When + viewModel.trackDashboardCourseClicked(courseID: "test-id", courseName: "Test Course") + + // Then + Verify(analytics, 1, .dashboardCourseClicked(courseID: .value("test-id"), courseName: .value("Test Course"))) + } +} diff --git a/Dashboard/DashboardTests/Presentation/PrimaryCourseDashboardViewModelTests.swift b/Dashboard/DashboardTests/Presentation/PrimaryCourseDashboardViewModelTests.swift new file mode 100644 index 000000000..c4083e705 --- /dev/null +++ b/Dashboard/DashboardTests/Presentation/PrimaryCourseDashboardViewModelTests.swift @@ -0,0 +1,213 @@ +// +// PrimaryCourseDashboardViewModelTests.swift +// Dashboard +// +// Created by Ivan Stepanok on 30.10.2024. +// + + +import SwiftyMocky +import XCTest +@testable import Core +@testable import Dashboard +import Combine +import SwiftUI + +final class PrimaryCourseDashboardViewModelTests: XCTestCase { + + var interactor: DashboardInteractorProtocolMock! + var connectivity: ConnectivityProtocolMock! + var analytics: DashboardAnalyticsMock! + var storage: CoreStorageMock! + var config: ConfigProtocolMock! + + override func setUp() { + super.setUp() + interactor = DashboardInteractorProtocolMock() + connectivity = ConnectivityProtocolMock() + analytics = DashboardAnalyticsMock() + storage = CoreStorageMock() + config = ConfigProtocolMock() + interactor = DashboardInteractorProtocolMock() + } + + let enrollment = PrimaryEnrollment( + primaryCourse: PrimaryCourse.init( + name: "Primary Course", + org: "OpenEdX", + courseID: "1", + hasAccess: true, + courseStart: Date(), + courseEnd: nil, + courseBanner: "https://example.com/banner.jpg", + futureAssignments: [], + pastAssignments: [], + progressEarned: 0, + progressPossible: 1, + lastVisitedBlockID: nil, + resumeTitle: nil + ), + courses: [ + CourseItem.init( + name: "Course", + org: "OpenEdX", + shortDescription: "short description", + imageURL: "https://examlpe.com/image.jpg", + hasAccess: true, + courseStart: nil, + courseEnd: nil, + enrollmentStart: nil, + enrollmentEnd: nil, + courseID: "2", + numPages: 1, + coursesCount: 3, + progressEarned: 0, + progressPossible: 2 + ) + ], + totalPages: 1, + count: 1 + ) + + func testGetEnrollmentsSuccess() async throws { + // Given + let viewModel = PrimaryCourseDashboardViewModel( + interactor: interactor, + connectivity: connectivity, + analytics: analytics, + config: config, + storage: storage + ) + + Given(connectivity, .isInternetAvaliable(getter: true)) + Given(interactor, .getPrimaryEnrollment(pageSize: .any, willReturn: enrollment)) + + // When + await viewModel.getEnrollments() + + // Then + Verify(interactor, 1, .getPrimaryEnrollment(pageSize: .value(UIDevice.current.userInterfaceIdiom == .pad ? 7 : 5))) + XCTAssertEqual(viewModel.enrollments, enrollment) + XCTAssertNil(viewModel.errorMessage) + XCTAssertFalse(viewModel.showError) + XCTAssertFalse(viewModel.fetchInProgress) + } + + func testGetEnrollmentsOfflineSuccess() async throws { + // Given + let viewModel = PrimaryCourseDashboardViewModel( + interactor: interactor, + connectivity: connectivity, + analytics: analytics, + config: config, + storage: storage + ) + + Given(connectivity, .isInternetAvaliable(getter: false)) + Given(interactor, .getPrimaryEnrollmentOffline(willReturn: enrollment)) + + // When + await viewModel.getEnrollments() + + // Then + Verify(interactor, 1, .getPrimaryEnrollmentOffline()) + XCTAssertEqual(viewModel.enrollments, enrollment) + XCTAssertNil(viewModel.errorMessage) + XCTAssertFalse(viewModel.showError) + XCTAssertFalse(viewModel.fetchInProgress) + } + + func testGetEnrollmentsNoCacheError() async throws { + // Given + let viewModel = PrimaryCourseDashboardViewModel( + interactor: interactor, + connectivity: connectivity, + analytics: analytics, + config: config, + storage: storage + ) + + Given(connectivity, .isInternetAvaliable(getter: true)) + Given(interactor, .getPrimaryEnrollment(pageSize: .any, willThrow: NoCachedDataError())) + + // When + await viewModel.getEnrollments() + + // Then + Verify(interactor, 1, .getPrimaryEnrollment(pageSize: .value(UIDevice.current.userInterfaceIdiom == .pad ? 7 : 5))) + XCTAssertNil(viewModel.enrollments) + XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.noCachedData) + XCTAssertTrue(viewModel.showError) + XCTAssertFalse(viewModel.fetchInProgress) + } + + func testGetEnrollmentsUnknownError() async throws { + // Given + let viewModel = PrimaryCourseDashboardViewModel( + interactor: interactor, + connectivity: connectivity, + analytics: analytics, + config: config, + storage: storage + ) + + Given(connectivity, .isInternetAvaliable(getter: true)) + Given(interactor, .getPrimaryEnrollment(pageSize: .any, willThrow: NSError())) + + // When + await viewModel.getEnrollments() + + // Then + Verify(interactor, 1, .getPrimaryEnrollment(pageSize: .value(UIDevice.current.userInterfaceIdiom == .pad ? 7 : 5))) + XCTAssertNil(viewModel.enrollments) + XCTAssertEqual(viewModel.errorMessage, CoreLocalization.Error.unknownError) + XCTAssertTrue(viewModel.showError) + XCTAssertFalse(viewModel.fetchInProgress) + } + + func testTrackDashboardCourseClicked() { + // Given + let viewModel = PrimaryCourseDashboardViewModel( + interactor: interactor, + connectivity: connectivity, + analytics: analytics, + config: config, + storage: storage + ) + + let courseID = "test-course-id" + let courseName = "Test Course" + + // When + viewModel.trackDashboardCourseClicked(courseID: courseID, courseName: courseName) + + // Then + Verify(analytics, 1, .dashboardCourseClicked(courseID: .value(courseID), courseName: .value(courseName))) + } + + func testNotificationCenterSubscriptions() async { + // Given + let viewModel = PrimaryCourseDashboardViewModel( + interactor: interactor, + connectivity: connectivity, + analytics: analytics, + config: config, + storage: storage + ) + + Given(connectivity, .isInternetAvaliable(getter: true)) + Given(interactor, .getPrimaryEnrollment(pageSize: .any, willReturn: enrollment)) + + // When + NotificationCenter.default.post(name: .onCourseEnrolled, object: nil) + NotificationCenter.default.post(name: .onblockCompletionRequested, object: nil) + NotificationCenter.default.post(name: .refreshEnrollments, object: nil) + + // Wait a bit for async operations to complete + try? await Task.sleep(nanoseconds: 100_000_000) + + // Then + // Verify that getEnrollments was called multiple times due to notifications + Verify(interactor, .getPrimaryEnrollment(pageSize: .any)) + } +} diff --git a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index d1f2c7a15..21cd06ec0 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -978,6 +978,899 @@ open class BaseRouterMock: BaseRouter, Mock { } } +// MARK: - CalendarManagerProtocol + +open class CalendarManagerProtocolMock: CalendarManagerProtocol, 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 createCalendarIfNeeded() { + addInvocation(.m_createCalendarIfNeeded) + let perform = methodPerformValue(.m_createCalendarIfNeeded) as? () -> Void + perform?() + } + + open func filterCoursesBySelected(fetchedCourses: [CourseForSync]) -> [CourseForSync] { + addInvocation(.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>.value(`fetchedCourses`))) + let perform = methodPerformValue(.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>.value(`fetchedCourses`))) as? ([CourseForSync]) -> Void + perform?(`fetchedCourses`) + var __value: [CourseForSync] + do { + __value = try methodReturnValue(.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>.value(`fetchedCourses`))).casted() + } catch { + onFatalFailure("Stub return value not specified for filterCoursesBySelected(fetchedCourses: [CourseForSync]). Use given") + Failure("Stub return value not specified for filterCoursesBySelected(fetchedCourses: [CourseForSync]). Use given") + } + return __value + } + + open func removeOldCalendar() { + addInvocation(.m_removeOldCalendar) + let perform = methodPerformValue(.m_removeOldCalendar) as? () -> Void + perform?() + } + + open func removeOutdatedEvents(courseID: String) { + addInvocation(.m_removeOutdatedEvents__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_removeOutdatedEvents__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + } + + open func syncCourse(courseID: String, courseName: String, dates: CourseDates) { + addInvocation(.m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(Parameter.value(`courseID`), Parameter.value(`courseName`), Parameter.value(`dates`))) + let perform = methodPerformValue(.m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(Parameter.value(`courseID`), Parameter.value(`courseName`), Parameter.value(`dates`))) as? (String, String, CourseDates) -> Void + perform?(`courseID`, `courseName`, `dates`) + } + + open func requestAccess() -> Bool { + addInvocation(.m_requestAccess) + let perform = methodPerformValue(.m_requestAccess) as? () -> Void + perform?() + var __value: Bool + do { + __value = try methodReturnValue(.m_requestAccess).casted() + } catch { + onFatalFailure("Stub return value not specified for requestAccess(). Use given") + Failure("Stub return value not specified for requestAccess(). Use given") + } + return __value + } + + open func courseStatus(courseID: String) -> SyncStatus { + addInvocation(.m_courseStatus__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_courseStatus__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + var __value: SyncStatus + do { + __value = try methodReturnValue(.m_courseStatus__courseID_courseID(Parameter.value(`courseID`))).casted() + } catch { + onFatalFailure("Stub return value not specified for courseStatus(courseID: String). Use given") + Failure("Stub return value not specified for courseStatus(courseID: String). Use given") + } + return __value + } + + open func clearAllData(removeCalendar: Bool) { + addInvocation(.m_clearAllData__removeCalendar_removeCalendar(Parameter.value(`removeCalendar`))) + let perform = methodPerformValue(.m_clearAllData__removeCalendar_removeCalendar(Parameter.value(`removeCalendar`))) as? (Bool) -> Void + perform?(`removeCalendar`) + } + + open func isDatesChanged(courseID: String, checksum: String) -> Bool { + addInvocation(.m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter.value(`courseID`), Parameter.value(`checksum`))) + let perform = methodPerformValue(.m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter.value(`courseID`), Parameter.value(`checksum`))) as? (String, String) -> Void + perform?(`courseID`, `checksum`) + var __value: Bool + do { + __value = try methodReturnValue(.m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter.value(`courseID`), Parameter.value(`checksum`))).casted() + } catch { + onFatalFailure("Stub return value not specified for isDatesChanged(courseID: String, checksum: String). Use given") + Failure("Stub return value not specified for isDatesChanged(courseID: String, checksum: String). Use given") + } + return __value + } + + + fileprivate enum MethodType { + case m_createCalendarIfNeeded + case m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>) + case m_removeOldCalendar + case m_removeOutdatedEvents__courseID_courseID(Parameter) + case m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(Parameter, Parameter, Parameter) + case m_requestAccess + case m_courseStatus__courseID_courseID(Parameter) + case m_clearAllData__removeCalendar_removeCalendar(Parameter) + case m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_createCalendarIfNeeded, .m_createCalendarIfNeeded): return .match + + case (.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(let lhsFetchedcourses), .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(let rhsFetchedcourses)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsFetchedcourses, rhs: rhsFetchedcourses, with: matcher), lhsFetchedcourses, rhsFetchedcourses, "fetchedCourses")) + return Matcher.ComparisonResult(results) + + case (.m_removeOldCalendar, .m_removeOldCalendar): return .match + + case (.m_removeOutdatedEvents__courseID_courseID(let lhsCourseid), .m_removeOutdatedEvents__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_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(let lhsCourseid, let lhsCoursename, let lhsDates), .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(let rhsCourseid, let rhsCoursename, let rhsDates)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDates, rhs: rhsDates, with: matcher), lhsDates, rhsDates, "dates")) + return Matcher.ComparisonResult(results) + + case (.m_requestAccess, .m_requestAccess): return .match + + case (.m_courseStatus__courseID_courseID(let lhsCourseid), .m_courseStatus__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_clearAllData__removeCalendar_removeCalendar(let lhsRemovecalendar), .m_clearAllData__removeCalendar_removeCalendar(let rhsRemovecalendar)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRemovecalendar, rhs: rhsRemovecalendar, with: matcher), lhsRemovecalendar, rhsRemovecalendar, "removeCalendar")) + return Matcher.ComparisonResult(results) + + case (.m_isDatesChanged__courseID_courseIDchecksum_checksum(let lhsCourseid, let lhsChecksum), .m_isDatesChanged__courseID_courseIDchecksum_checksum(let rhsCourseid, let rhsChecksum)): + 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: lhsChecksum, rhs: rhsChecksum, with: matcher), lhsChecksum, rhsChecksum, "checksum")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .m_createCalendarIfNeeded: return 0 + case let .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(p0): return p0.intValue + case .m_removeOldCalendar: return 0 + case let .m_removeOutdatedEvents__courseID_courseID(p0): return p0.intValue + case let .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case .m_requestAccess: return 0 + case let .m_courseStatus__courseID_courseID(p0): return p0.intValue + case let .m_clearAllData__removeCalendar_removeCalendar(p0): return p0.intValue + case let .m_isDatesChanged__courseID_courseIDchecksum_checksum(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_createCalendarIfNeeded: return ".createCalendarIfNeeded()" + case .m_filterCoursesBySelected__fetchedCourses_fetchedCourses: return ".filterCoursesBySelected(fetchedCourses:)" + case .m_removeOldCalendar: return ".removeOldCalendar()" + case .m_removeOutdatedEvents__courseID_courseID: return ".removeOutdatedEvents(courseID:)" + case .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates: return ".syncCourse(courseID:courseName:dates:)" + case .m_requestAccess: return ".requestAccess()" + case .m_courseStatus__courseID_courseID: return ".courseStatus(courseID:)" + case .m_clearAllData__removeCalendar_removeCalendar: return ".clearAllData(removeCalendar:)" + case .m_isDatesChanged__courseID_courseIDchecksum_checksum: return ".isDatesChanged(courseID:checksum:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>, willReturn: [CourseForSync]...) -> MethodStub { + return Given(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func requestAccess(willReturn: Bool...) -> MethodStub { + return Given(method: .m_requestAccess, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func courseStatus(courseID: Parameter, willReturn: SyncStatus...) -> MethodStub { + return Given(method: .m_courseStatus__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func isDatesChanged(courseID: Parameter, checksum: Parameter, willReturn: Bool...) -> MethodStub { + return Given(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>, willProduce: (Stubber<[CourseForSync]>) -> Void) -> MethodStub { + let willReturn: [[CourseForSync]] = [] + let given: Given = { return Given(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseForSync]).self) + willProduce(stubber) + return given + } + public static func requestAccess(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Bool] = [] + let given: Given = { return Given(method: .m_requestAccess, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Bool).self) + willProduce(stubber) + return given + } + public static func courseStatus(courseID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [SyncStatus] = [] + let given: Given = { return Given(method: .m_courseStatus__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (SyncStatus).self) + willProduce(stubber) + return given + } + public static func isDatesChanged(courseID: Parameter, checksum: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Bool] = [] + let given: Given = { return Given(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Bool).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func createCalendarIfNeeded() -> Verify { return Verify(method: .m_createCalendarIfNeeded)} + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>) -> Verify { return Verify(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`))} + public static func removeOldCalendar() -> Verify { return Verify(method: .m_removeOldCalendar)} + public static func removeOutdatedEvents(courseID: Parameter) -> Verify { return Verify(method: .m_removeOutdatedEvents__courseID_courseID(`courseID`))} + public static func syncCourse(courseID: Parameter, courseName: Parameter, dates: Parameter) -> Verify { return Verify(method: .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(`courseID`, `courseName`, `dates`))} + public static func requestAccess() -> Verify { return Verify(method: .m_requestAccess)} + public static func courseStatus(courseID: Parameter) -> Verify { return Verify(method: .m_courseStatus__courseID_courseID(`courseID`))} + public static func clearAllData(removeCalendar: Parameter) -> Verify { return Verify(method: .m_clearAllData__removeCalendar_removeCalendar(`removeCalendar`))} + public static func isDatesChanged(courseID: Parameter, checksum: Parameter) -> Verify { return Verify(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func createCalendarIfNeeded(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_createCalendarIfNeeded, performs: perform) + } + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>, perform: @escaping ([CourseForSync]) -> Void) -> Perform { + return Perform(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`), performs: perform) + } + public static func removeOldCalendar(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_removeOldCalendar, performs: perform) + } + public static func removeOutdatedEvents(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_removeOutdatedEvents__courseID_courseID(`courseID`), performs: perform) + } + public static func syncCourse(courseID: Parameter, courseName: Parameter, dates: Parameter, perform: @escaping (String, String, CourseDates) -> Void) -> Perform { + return Perform(method: .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(`courseID`, `courseName`, `dates`), performs: perform) + } + public static func requestAccess(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_requestAccess, performs: perform) + } + public static func courseStatus(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_courseStatus__courseID_courseID(`courseID`), performs: perform) + } + public static func clearAllData(removeCalendar: Parameter, perform: @escaping (Bool) -> Void) -> Perform { + return Perform(method: .m_clearAllData__removeCalendar_removeCalendar(`removeCalendar`), performs: perform) + } + public static func isDatesChanged(courseID: Parameter, checksum: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`), 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: - ConfigProtocol + +open class ConfigProtocolMock: ConfigProtocol, 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 baseURL: URL { + get { invocations.append(.p_baseURL_get); return __p_baseURL ?? givenGetterValue(.p_baseURL_get, "ConfigProtocolMock - stub value for baseURL was not defined") } + } + private var __p_baseURL: (URL)? + + public var baseSSOURL: URL { + get { invocations.append(.p_baseSSOURL_get); return __p_baseSSOURL ?? givenGetterValue(.p_baseSSOURL_get, "ConfigProtocolMock - stub value for baseSSOURL was not defined") } + } + private var __p_baseSSOURL: (URL)? + + public var ssoFinishedURL: URL { + get { invocations.append(.p_ssoFinishedURL_get); return __p_ssoFinishedURL ?? givenGetterValue(.p_ssoFinishedURL_get, "ConfigProtocolMock - stub value for ssoFinishedURL was not defined") } + } + private var __p_ssoFinishedURL: (URL)? + + public var ssoButtonTitle: [String: Any] { + get { invocations.append(.p_ssoButtonTitle_get); return __p_ssoButtonTitle ?? givenGetterValue(.p_ssoButtonTitle_get, "ConfigProtocolMock - stub value for ssoButtonTitle was not defined") } + } + private var __p_ssoButtonTitle: ([String: Any])? + + public var oAuthClientId: String { + get { invocations.append(.p_oAuthClientId_get); return __p_oAuthClientId ?? givenGetterValue(.p_oAuthClientId_get, "ConfigProtocolMock - stub value for oAuthClientId was not defined") } + } + private var __p_oAuthClientId: (String)? + + public var tokenType: TokenType { + get { invocations.append(.p_tokenType_get); return __p_tokenType ?? givenGetterValue(.p_tokenType_get, "ConfigProtocolMock - stub value for tokenType was not defined") } + } + private var __p_tokenType: (TokenType)? + + public var feedbackEmail: String { + get { invocations.append(.p_feedbackEmail_get); return __p_feedbackEmail ?? givenGetterValue(.p_feedbackEmail_get, "ConfigProtocolMock - stub value for feedbackEmail was not defined") } + } + private var __p_feedbackEmail: (String)? + + public var appStoreLink: String { + get { invocations.append(.p_appStoreLink_get); return __p_appStoreLink ?? givenGetterValue(.p_appStoreLink_get, "ConfigProtocolMock - stub value for appStoreLink was not defined") } + } + private var __p_appStoreLink: (String)? + + public var faq: URL? { + get { invocations.append(.p_faq_get); return __p_faq ?? optionalGivenGetterValue(.p_faq_get, "ConfigProtocolMock - stub value for faq was not defined") } + } + private var __p_faq: (URL)? + + public var platformName: String { + get { invocations.append(.p_platformName_get); return __p_platformName ?? givenGetterValue(.p_platformName_get, "ConfigProtocolMock - stub value for platformName was not defined") } + } + private var __p_platformName: (String)? + + public var agreement: AgreementConfig { + get { invocations.append(.p_agreement_get); return __p_agreement ?? givenGetterValue(.p_agreement_get, "ConfigProtocolMock - stub value for agreement was not defined") } + } + private var __p_agreement: (AgreementConfig)? + + public var firebase: FirebaseConfig { + get { invocations.append(.p_firebase_get); return __p_firebase ?? givenGetterValue(.p_firebase_get, "ConfigProtocolMock - stub value for firebase was not defined") } + } + private var __p_firebase: (FirebaseConfig)? + + public var facebook: FacebookConfig { + get { invocations.append(.p_facebook_get); return __p_facebook ?? givenGetterValue(.p_facebook_get, "ConfigProtocolMock - stub value for facebook was not defined") } + } + private var __p_facebook: (FacebookConfig)? + + public var microsoft: MicrosoftConfig { + get { invocations.append(.p_microsoft_get); return __p_microsoft ?? givenGetterValue(.p_microsoft_get, "ConfigProtocolMock - stub value for microsoft was not defined") } + } + private var __p_microsoft: (MicrosoftConfig)? + + public var google: GoogleConfig { + get { invocations.append(.p_google_get); return __p_google ?? givenGetterValue(.p_google_get, "ConfigProtocolMock - stub value for google was not defined") } + } + private var __p_google: (GoogleConfig)? + + public var appleSignIn: AppleSignInConfig { + get { invocations.append(.p_appleSignIn_get); return __p_appleSignIn ?? givenGetterValue(.p_appleSignIn_get, "ConfigProtocolMock - stub value for appleSignIn was not defined") } + } + private var __p_appleSignIn: (AppleSignInConfig)? + + public var features: FeaturesConfig { + get { invocations.append(.p_features_get); return __p_features ?? givenGetterValue(.p_features_get, "ConfigProtocolMock - stub value for features was not defined") } + } + private var __p_features: (FeaturesConfig)? + + public var theme: ThemeConfig { + get { invocations.append(.p_theme_get); return __p_theme ?? givenGetterValue(.p_theme_get, "ConfigProtocolMock - stub value for theme was not defined") } + } + private var __p_theme: (ThemeConfig)? + + public var uiComponents: UIComponentsConfig { + get { invocations.append(.p_uiComponents_get); return __p_uiComponents ?? givenGetterValue(.p_uiComponents_get, "ConfigProtocolMock - stub value for uiComponents was not defined") } + } + private var __p_uiComponents: (UIComponentsConfig)? + + public var discovery: DiscoveryConfig { + get { invocations.append(.p_discovery_get); return __p_discovery ?? givenGetterValue(.p_discovery_get, "ConfigProtocolMock - stub value for discovery was not defined") } + } + private var __p_discovery: (DiscoveryConfig)? + + public var dashboard: DashboardConfig { + get { invocations.append(.p_dashboard_get); return __p_dashboard ?? givenGetterValue(.p_dashboard_get, "ConfigProtocolMock - stub value for dashboard was not defined") } + } + private var __p_dashboard: (DashboardConfig)? + + public var braze: BrazeConfig { + get { invocations.append(.p_braze_get); return __p_braze ?? givenGetterValue(.p_braze_get, "ConfigProtocolMock - stub value for braze was not defined") } + } + private var __p_braze: (BrazeConfig)? + + public var branch: BranchConfig { + get { invocations.append(.p_branch_get); return __p_branch ?? givenGetterValue(.p_branch_get, "ConfigProtocolMock - stub value for branch was not defined") } + } + private var __p_branch: (BranchConfig)? + + public var segment: SegmentConfig { + get { invocations.append(.p_segment_get); return __p_segment ?? givenGetterValue(.p_segment_get, "ConfigProtocolMock - stub value for segment was not defined") } + } + private var __p_segment: (SegmentConfig)? + + public var program: DiscoveryConfig { + get { invocations.append(.p_program_get); return __p_program ?? givenGetterValue(.p_program_get, "ConfigProtocolMock - stub value for program was not defined") } + } + private var __p_program: (DiscoveryConfig)? + + public var URIScheme: String { + get { invocations.append(.p_URIScheme_get); return __p_URIScheme ?? givenGetterValue(.p_URIScheme_get, "ConfigProtocolMock - stub value for URIScheme was not defined") } + } + private var __p_URIScheme: (String)? + + public var fullStory: FullStoryConfig { + get { invocations.append(.p_fullStory_get); return __p_fullStory ?? givenGetterValue(.p_fullStory_get, "ConfigProtocolMock - stub value for fullStory was not defined") } + } + private var __p_fullStory: (FullStoryConfig)? + + + + + + + fileprivate enum MethodType { + case p_baseURL_get + case p_baseSSOURL_get + case p_ssoFinishedURL_get + case p_ssoButtonTitle_get + case p_oAuthClientId_get + case p_tokenType_get + case p_feedbackEmail_get + case p_appStoreLink_get + case p_faq_get + case p_platformName_get + case p_agreement_get + case p_firebase_get + case p_facebook_get + case p_microsoft_get + case p_google_get + case p_appleSignIn_get + case p_features_get + case p_theme_get + case p_uiComponents_get + case p_discovery_get + case p_dashboard_get + case p_braze_get + case p_branch_get + case p_segment_get + case p_program_get + case p_URIScheme_get + case p_fullStory_get + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { case (.p_baseURL_get,.p_baseURL_get): return Matcher.ComparisonResult.match + case (.p_baseSSOURL_get,.p_baseSSOURL_get): return Matcher.ComparisonResult.match + case (.p_ssoFinishedURL_get,.p_ssoFinishedURL_get): return Matcher.ComparisonResult.match + case (.p_ssoButtonTitle_get,.p_ssoButtonTitle_get): return Matcher.ComparisonResult.match + case (.p_oAuthClientId_get,.p_oAuthClientId_get): return Matcher.ComparisonResult.match + case (.p_tokenType_get,.p_tokenType_get): return Matcher.ComparisonResult.match + case (.p_feedbackEmail_get,.p_feedbackEmail_get): return Matcher.ComparisonResult.match + case (.p_appStoreLink_get,.p_appStoreLink_get): return Matcher.ComparisonResult.match + case (.p_faq_get,.p_faq_get): return Matcher.ComparisonResult.match + case (.p_platformName_get,.p_platformName_get): return Matcher.ComparisonResult.match + case (.p_agreement_get,.p_agreement_get): return Matcher.ComparisonResult.match + case (.p_firebase_get,.p_firebase_get): return Matcher.ComparisonResult.match + case (.p_facebook_get,.p_facebook_get): return Matcher.ComparisonResult.match + case (.p_microsoft_get,.p_microsoft_get): return Matcher.ComparisonResult.match + case (.p_google_get,.p_google_get): return Matcher.ComparisonResult.match + case (.p_appleSignIn_get,.p_appleSignIn_get): return Matcher.ComparisonResult.match + case (.p_features_get,.p_features_get): return Matcher.ComparisonResult.match + case (.p_theme_get,.p_theme_get): return Matcher.ComparisonResult.match + case (.p_uiComponents_get,.p_uiComponents_get): return Matcher.ComparisonResult.match + case (.p_discovery_get,.p_discovery_get): return Matcher.ComparisonResult.match + case (.p_dashboard_get,.p_dashboard_get): return Matcher.ComparisonResult.match + case (.p_braze_get,.p_braze_get): return Matcher.ComparisonResult.match + case (.p_branch_get,.p_branch_get): return Matcher.ComparisonResult.match + case (.p_segment_get,.p_segment_get): return Matcher.ComparisonResult.match + case (.p_program_get,.p_program_get): return Matcher.ComparisonResult.match + case (.p_URIScheme_get,.p_URIScheme_get): return Matcher.ComparisonResult.match + case (.p_fullStory_get,.p_fullStory_get): return Matcher.ComparisonResult.match + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .p_baseURL_get: return 0 + case .p_baseSSOURL_get: return 0 + case .p_ssoFinishedURL_get: return 0 + case .p_ssoButtonTitle_get: return 0 + case .p_oAuthClientId_get: return 0 + case .p_tokenType_get: return 0 + case .p_feedbackEmail_get: return 0 + case .p_appStoreLink_get: return 0 + case .p_faq_get: return 0 + case .p_platformName_get: return 0 + case .p_agreement_get: return 0 + case .p_firebase_get: return 0 + case .p_facebook_get: return 0 + case .p_microsoft_get: return 0 + case .p_google_get: return 0 + case .p_appleSignIn_get: return 0 + case .p_features_get: return 0 + case .p_theme_get: return 0 + case .p_uiComponents_get: return 0 + case .p_discovery_get: return 0 + case .p_dashboard_get: return 0 + case .p_braze_get: return 0 + case .p_branch_get: return 0 + case .p_segment_get: return 0 + case .p_program_get: return 0 + case .p_URIScheme_get: return 0 + case .p_fullStory_get: return 0 + } + } + func assertionName() -> String { + switch self { + case .p_baseURL_get: return "[get] .baseURL" + case .p_baseSSOURL_get: return "[get] .baseSSOURL" + case .p_ssoFinishedURL_get: return "[get] .ssoFinishedURL" + case .p_ssoButtonTitle_get: return "[get] .ssoButtonTitle" + case .p_oAuthClientId_get: return "[get] .oAuthClientId" + case .p_tokenType_get: return "[get] .tokenType" + case .p_feedbackEmail_get: return "[get] .feedbackEmail" + case .p_appStoreLink_get: return "[get] .appStoreLink" + case .p_faq_get: return "[get] .faq" + case .p_platformName_get: return "[get] .platformName" + case .p_agreement_get: return "[get] .agreement" + case .p_firebase_get: return "[get] .firebase" + case .p_facebook_get: return "[get] .facebook" + case .p_microsoft_get: return "[get] .microsoft" + case .p_google_get: return "[get] .google" + case .p_appleSignIn_get: return "[get] .appleSignIn" + case .p_features_get: return "[get] .features" + case .p_theme_get: return "[get] .theme" + case .p_uiComponents_get: return "[get] .uiComponents" + case .p_discovery_get: return "[get] .discovery" + case .p_dashboard_get: return "[get] .dashboard" + case .p_braze_get: return "[get] .braze" + case .p_branch_get: return "[get] .branch" + case .p_segment_get: return "[get] .segment" + case .p_program_get: return "[get] .program" + case .p_URIScheme_get: return "[get] .URIScheme" + case .p_fullStory_get: return "[get] .fullStory" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func baseURL(getter defaultValue: URL...) -> PropertyStub { + return Given(method: .p_baseURL_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func baseSSOURL(getter defaultValue: URL...) -> PropertyStub { + return Given(method: .p_baseSSOURL_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func ssoFinishedURL(getter defaultValue: URL...) -> PropertyStub { + return Given(method: .p_ssoFinishedURL_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func ssoButtonTitle(getter defaultValue: [String: Any]...) -> PropertyStub { + return Given(method: .p_ssoButtonTitle_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func oAuthClientId(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_oAuthClientId_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func tokenType(getter defaultValue: TokenType...) -> PropertyStub { + return Given(method: .p_tokenType_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func feedbackEmail(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_feedbackEmail_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appStoreLink(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_appStoreLink_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func faq(getter defaultValue: URL?...) -> PropertyStub { + return Given(method: .p_faq_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func platformName(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_platformName_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func agreement(getter defaultValue: AgreementConfig...) -> PropertyStub { + return Given(method: .p_agreement_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func firebase(getter defaultValue: FirebaseConfig...) -> PropertyStub { + return Given(method: .p_firebase_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func facebook(getter defaultValue: FacebookConfig...) -> PropertyStub { + return Given(method: .p_facebook_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func microsoft(getter defaultValue: MicrosoftConfig...) -> PropertyStub { + return Given(method: .p_microsoft_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func google(getter defaultValue: GoogleConfig...) -> PropertyStub { + return Given(method: .p_google_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appleSignIn(getter defaultValue: AppleSignInConfig...) -> PropertyStub { + return Given(method: .p_appleSignIn_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func features(getter defaultValue: FeaturesConfig...) -> PropertyStub { + return Given(method: .p_features_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func theme(getter defaultValue: ThemeConfig...) -> PropertyStub { + return Given(method: .p_theme_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func uiComponents(getter defaultValue: UIComponentsConfig...) -> PropertyStub { + return Given(method: .p_uiComponents_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func discovery(getter defaultValue: DiscoveryConfig...) -> PropertyStub { + return Given(method: .p_discovery_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func dashboard(getter defaultValue: DashboardConfig...) -> PropertyStub { + return Given(method: .p_dashboard_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func braze(getter defaultValue: BrazeConfig...) -> PropertyStub { + return Given(method: .p_braze_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func branch(getter defaultValue: BranchConfig...) -> PropertyStub { + return Given(method: .p_branch_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func segment(getter defaultValue: SegmentConfig...) -> PropertyStub { + return Given(method: .p_segment_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func program(getter defaultValue: DiscoveryConfig...) -> PropertyStub { + return Given(method: .p_program_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func URIScheme(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_URIScheme_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func fullStory(getter defaultValue: FullStoryConfig...) -> PropertyStub { + return Given(method: .p_fullStory_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static var baseURL: Verify { return Verify(method: .p_baseURL_get) } + public static var baseSSOURL: Verify { return Verify(method: .p_baseSSOURL_get) } + public static var ssoFinishedURL: Verify { return Verify(method: .p_ssoFinishedURL_get) } + public static var ssoButtonTitle: Verify { return Verify(method: .p_ssoButtonTitle_get) } + public static var oAuthClientId: Verify { return Verify(method: .p_oAuthClientId_get) } + public static var tokenType: Verify { return Verify(method: .p_tokenType_get) } + public static var feedbackEmail: Verify { return Verify(method: .p_feedbackEmail_get) } + public static var appStoreLink: Verify { return Verify(method: .p_appStoreLink_get) } + public static var faq: Verify { return Verify(method: .p_faq_get) } + public static var platformName: Verify { return Verify(method: .p_platformName_get) } + public static var agreement: Verify { return Verify(method: .p_agreement_get) } + public static var firebase: Verify { return Verify(method: .p_firebase_get) } + public static var facebook: Verify { return Verify(method: .p_facebook_get) } + public static var microsoft: Verify { return Verify(method: .p_microsoft_get) } + public static var google: Verify { return Verify(method: .p_google_get) } + public static var appleSignIn: Verify { return Verify(method: .p_appleSignIn_get) } + public static var features: Verify { return Verify(method: .p_features_get) } + public static var theme: Verify { return Verify(method: .p_theme_get) } + public static var uiComponents: Verify { return Verify(method: .p_uiComponents_get) } + public static var discovery: Verify { return Verify(method: .p_discovery_get) } + public static var dashboard: Verify { return Verify(method: .p_dashboard_get) } + public static var braze: Verify { return Verify(method: .p_braze_get) } + public static var branch: Verify { return Verify(method: .p_branch_get) } + public static var segment: Verify { return Verify(method: .p_segment_get) } + public static var program: Verify { return Verify(method: .p_program_get) } + public static var URIScheme: Verify { return Verify(method: .p_URIScheme_get) } + public static var fullStory: Verify { return Verify(method: .p_fullStory_get) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + } + + 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: - ConnectivityProtocol open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index 1f1237234..e3a6b6361 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -978,6 +978,899 @@ open class BaseRouterMock: BaseRouter, Mock { } } +// MARK: - CalendarManagerProtocol + +open class CalendarManagerProtocolMock: CalendarManagerProtocol, 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 createCalendarIfNeeded() { + addInvocation(.m_createCalendarIfNeeded) + let perform = methodPerformValue(.m_createCalendarIfNeeded) as? () -> Void + perform?() + } + + open func filterCoursesBySelected(fetchedCourses: [CourseForSync]) -> [CourseForSync] { + addInvocation(.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>.value(`fetchedCourses`))) + let perform = methodPerformValue(.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>.value(`fetchedCourses`))) as? ([CourseForSync]) -> Void + perform?(`fetchedCourses`) + var __value: [CourseForSync] + do { + __value = try methodReturnValue(.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>.value(`fetchedCourses`))).casted() + } catch { + onFatalFailure("Stub return value not specified for filterCoursesBySelected(fetchedCourses: [CourseForSync]). Use given") + Failure("Stub return value not specified for filterCoursesBySelected(fetchedCourses: [CourseForSync]). Use given") + } + return __value + } + + open func removeOldCalendar() { + addInvocation(.m_removeOldCalendar) + let perform = methodPerformValue(.m_removeOldCalendar) as? () -> Void + perform?() + } + + open func removeOutdatedEvents(courseID: String) { + addInvocation(.m_removeOutdatedEvents__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_removeOutdatedEvents__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + } + + open func syncCourse(courseID: String, courseName: String, dates: CourseDates) { + addInvocation(.m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(Parameter.value(`courseID`), Parameter.value(`courseName`), Parameter.value(`dates`))) + let perform = methodPerformValue(.m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(Parameter.value(`courseID`), Parameter.value(`courseName`), Parameter.value(`dates`))) as? (String, String, CourseDates) -> Void + perform?(`courseID`, `courseName`, `dates`) + } + + open func requestAccess() -> Bool { + addInvocation(.m_requestAccess) + let perform = methodPerformValue(.m_requestAccess) as? () -> Void + perform?() + var __value: Bool + do { + __value = try methodReturnValue(.m_requestAccess).casted() + } catch { + onFatalFailure("Stub return value not specified for requestAccess(). Use given") + Failure("Stub return value not specified for requestAccess(). Use given") + } + return __value + } + + open func courseStatus(courseID: String) -> SyncStatus { + addInvocation(.m_courseStatus__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_courseStatus__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + var __value: SyncStatus + do { + __value = try methodReturnValue(.m_courseStatus__courseID_courseID(Parameter.value(`courseID`))).casted() + } catch { + onFatalFailure("Stub return value not specified for courseStatus(courseID: String). Use given") + Failure("Stub return value not specified for courseStatus(courseID: String). Use given") + } + return __value + } + + open func clearAllData(removeCalendar: Bool) { + addInvocation(.m_clearAllData__removeCalendar_removeCalendar(Parameter.value(`removeCalendar`))) + let perform = methodPerformValue(.m_clearAllData__removeCalendar_removeCalendar(Parameter.value(`removeCalendar`))) as? (Bool) -> Void + perform?(`removeCalendar`) + } + + open func isDatesChanged(courseID: String, checksum: String) -> Bool { + addInvocation(.m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter.value(`courseID`), Parameter.value(`checksum`))) + let perform = methodPerformValue(.m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter.value(`courseID`), Parameter.value(`checksum`))) as? (String, String) -> Void + perform?(`courseID`, `checksum`) + var __value: Bool + do { + __value = try methodReturnValue(.m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter.value(`courseID`), Parameter.value(`checksum`))).casted() + } catch { + onFatalFailure("Stub return value not specified for isDatesChanged(courseID: String, checksum: String). Use given") + Failure("Stub return value not specified for isDatesChanged(courseID: String, checksum: String). Use given") + } + return __value + } + + + fileprivate enum MethodType { + case m_createCalendarIfNeeded + case m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>) + case m_removeOldCalendar + case m_removeOutdatedEvents__courseID_courseID(Parameter) + case m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(Parameter, Parameter, Parameter) + case m_requestAccess + case m_courseStatus__courseID_courseID(Parameter) + case m_clearAllData__removeCalendar_removeCalendar(Parameter) + case m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter, Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_createCalendarIfNeeded, .m_createCalendarIfNeeded): return .match + + case (.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(let lhsFetchedcourses), .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(let rhsFetchedcourses)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsFetchedcourses, rhs: rhsFetchedcourses, with: matcher), lhsFetchedcourses, rhsFetchedcourses, "fetchedCourses")) + return Matcher.ComparisonResult(results) + + case (.m_removeOldCalendar, .m_removeOldCalendar): return .match + + case (.m_removeOutdatedEvents__courseID_courseID(let lhsCourseid), .m_removeOutdatedEvents__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_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(let lhsCourseid, let lhsCoursename, let lhsDates), .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(let rhsCourseid, let rhsCoursename, let rhsDates)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDates, rhs: rhsDates, with: matcher), lhsDates, rhsDates, "dates")) + return Matcher.ComparisonResult(results) + + case (.m_requestAccess, .m_requestAccess): return .match + + case (.m_courseStatus__courseID_courseID(let lhsCourseid), .m_courseStatus__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_clearAllData__removeCalendar_removeCalendar(let lhsRemovecalendar), .m_clearAllData__removeCalendar_removeCalendar(let rhsRemovecalendar)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRemovecalendar, rhs: rhsRemovecalendar, with: matcher), lhsRemovecalendar, rhsRemovecalendar, "removeCalendar")) + return Matcher.ComparisonResult(results) + + case (.m_isDatesChanged__courseID_courseIDchecksum_checksum(let lhsCourseid, let lhsChecksum), .m_isDatesChanged__courseID_courseIDchecksum_checksum(let rhsCourseid, let rhsChecksum)): + 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: lhsChecksum, rhs: rhsChecksum, with: matcher), lhsChecksum, rhsChecksum, "checksum")) + return Matcher.ComparisonResult(results) + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .m_createCalendarIfNeeded: return 0 + case let .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(p0): return p0.intValue + case .m_removeOldCalendar: return 0 + case let .m_removeOutdatedEvents__courseID_courseID(p0): return p0.intValue + case let .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case .m_requestAccess: return 0 + case let .m_courseStatus__courseID_courseID(p0): return p0.intValue + case let .m_clearAllData__removeCalendar_removeCalendar(p0): return p0.intValue + case let .m_isDatesChanged__courseID_courseIDchecksum_checksum(p0, p1): return p0.intValue + p1.intValue + } + } + func assertionName() -> String { + switch self { + case .m_createCalendarIfNeeded: return ".createCalendarIfNeeded()" + case .m_filterCoursesBySelected__fetchedCourses_fetchedCourses: return ".filterCoursesBySelected(fetchedCourses:)" + case .m_removeOldCalendar: return ".removeOldCalendar()" + case .m_removeOutdatedEvents__courseID_courseID: return ".removeOutdatedEvents(courseID:)" + case .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates: return ".syncCourse(courseID:courseName:dates:)" + case .m_requestAccess: return ".requestAccess()" + case .m_courseStatus__courseID_courseID: return ".courseStatus(courseID:)" + case .m_clearAllData__removeCalendar_removeCalendar: return ".clearAllData(removeCalendar:)" + case .m_isDatesChanged__courseID_courseIDchecksum_checksum: return ".isDatesChanged(courseID:checksum:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>, willReturn: [CourseForSync]...) -> MethodStub { + return Given(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func requestAccess(willReturn: Bool...) -> MethodStub { + return Given(method: .m_requestAccess, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func courseStatus(courseID: Parameter, willReturn: SyncStatus...) -> MethodStub { + return Given(method: .m_courseStatus__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func isDatesChanged(courseID: Parameter, checksum: Parameter, willReturn: Bool...) -> MethodStub { + return Given(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>, willProduce: (Stubber<[CourseForSync]>) -> Void) -> MethodStub { + let willReturn: [[CourseForSync]] = [] + let given: Given = { return Given(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseForSync]).self) + willProduce(stubber) + return given + } + public static func requestAccess(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Bool] = [] + let given: Given = { return Given(method: .m_requestAccess, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Bool).self) + willProduce(stubber) + return given + } + public static func courseStatus(courseID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [SyncStatus] = [] + let given: Given = { return Given(method: .m_courseStatus__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (SyncStatus).self) + willProduce(stubber) + return given + } + public static func isDatesChanged(courseID: Parameter, checksum: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Bool] = [] + let given: Given = { return Given(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Bool).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func createCalendarIfNeeded() -> Verify { return Verify(method: .m_createCalendarIfNeeded)} + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>) -> Verify { return Verify(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`))} + public static func removeOldCalendar() -> Verify { return Verify(method: .m_removeOldCalendar)} + public static func removeOutdatedEvents(courseID: Parameter) -> Verify { return Verify(method: .m_removeOutdatedEvents__courseID_courseID(`courseID`))} + public static func syncCourse(courseID: Parameter, courseName: Parameter, dates: Parameter) -> Verify { return Verify(method: .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(`courseID`, `courseName`, `dates`))} + public static func requestAccess() -> Verify { return Verify(method: .m_requestAccess)} + public static func courseStatus(courseID: Parameter) -> Verify { return Verify(method: .m_courseStatus__courseID_courseID(`courseID`))} + public static func clearAllData(removeCalendar: Parameter) -> Verify { return Verify(method: .m_clearAllData__removeCalendar_removeCalendar(`removeCalendar`))} + public static func isDatesChanged(courseID: Parameter, checksum: Parameter) -> Verify { return Verify(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func createCalendarIfNeeded(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_createCalendarIfNeeded, performs: perform) + } + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>, perform: @escaping ([CourseForSync]) -> Void) -> Perform { + return Perform(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`), performs: perform) + } + public static func removeOldCalendar(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_removeOldCalendar, performs: perform) + } + public static func removeOutdatedEvents(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_removeOutdatedEvents__courseID_courseID(`courseID`), performs: perform) + } + public static func syncCourse(courseID: Parameter, courseName: Parameter, dates: Parameter, perform: @escaping (String, String, CourseDates) -> Void) -> Perform { + return Perform(method: .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(`courseID`, `courseName`, `dates`), performs: perform) + } + public static func requestAccess(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_requestAccess, performs: perform) + } + public static func courseStatus(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_courseStatus__courseID_courseID(`courseID`), performs: perform) + } + public static func clearAllData(removeCalendar: Parameter, perform: @escaping (Bool) -> Void) -> Perform { + return Perform(method: .m_clearAllData__removeCalendar_removeCalendar(`removeCalendar`), performs: perform) + } + public static func isDatesChanged(courseID: Parameter, checksum: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`), 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: - ConfigProtocol + +open class ConfigProtocolMock: ConfigProtocol, 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 baseURL: URL { + get { invocations.append(.p_baseURL_get); return __p_baseURL ?? givenGetterValue(.p_baseURL_get, "ConfigProtocolMock - stub value for baseURL was not defined") } + } + private var __p_baseURL: (URL)? + + public var baseSSOURL: URL { + get { invocations.append(.p_baseSSOURL_get); return __p_baseSSOURL ?? givenGetterValue(.p_baseSSOURL_get, "ConfigProtocolMock - stub value for baseSSOURL was not defined") } + } + private var __p_baseSSOURL: (URL)? + + public var ssoFinishedURL: URL { + get { invocations.append(.p_ssoFinishedURL_get); return __p_ssoFinishedURL ?? givenGetterValue(.p_ssoFinishedURL_get, "ConfigProtocolMock - stub value for ssoFinishedURL was not defined") } + } + private var __p_ssoFinishedURL: (URL)? + + public var ssoButtonTitle: [String: Any] { + get { invocations.append(.p_ssoButtonTitle_get); return __p_ssoButtonTitle ?? givenGetterValue(.p_ssoButtonTitle_get, "ConfigProtocolMock - stub value for ssoButtonTitle was not defined") } + } + private var __p_ssoButtonTitle: ([String: Any])? + + public var oAuthClientId: String { + get { invocations.append(.p_oAuthClientId_get); return __p_oAuthClientId ?? givenGetterValue(.p_oAuthClientId_get, "ConfigProtocolMock - stub value for oAuthClientId was not defined") } + } + private var __p_oAuthClientId: (String)? + + public var tokenType: TokenType { + get { invocations.append(.p_tokenType_get); return __p_tokenType ?? givenGetterValue(.p_tokenType_get, "ConfigProtocolMock - stub value for tokenType was not defined") } + } + private var __p_tokenType: (TokenType)? + + public var feedbackEmail: String { + get { invocations.append(.p_feedbackEmail_get); return __p_feedbackEmail ?? givenGetterValue(.p_feedbackEmail_get, "ConfigProtocolMock - stub value for feedbackEmail was not defined") } + } + private var __p_feedbackEmail: (String)? + + public var appStoreLink: String { + get { invocations.append(.p_appStoreLink_get); return __p_appStoreLink ?? givenGetterValue(.p_appStoreLink_get, "ConfigProtocolMock - stub value for appStoreLink was not defined") } + } + private var __p_appStoreLink: (String)? + + public var faq: URL? { + get { invocations.append(.p_faq_get); return __p_faq ?? optionalGivenGetterValue(.p_faq_get, "ConfigProtocolMock - stub value for faq was not defined") } + } + private var __p_faq: (URL)? + + public var platformName: String { + get { invocations.append(.p_platformName_get); return __p_platformName ?? givenGetterValue(.p_platformName_get, "ConfigProtocolMock - stub value for platformName was not defined") } + } + private var __p_platformName: (String)? + + public var agreement: AgreementConfig { + get { invocations.append(.p_agreement_get); return __p_agreement ?? givenGetterValue(.p_agreement_get, "ConfigProtocolMock - stub value for agreement was not defined") } + } + private var __p_agreement: (AgreementConfig)? + + public var firebase: FirebaseConfig { + get { invocations.append(.p_firebase_get); return __p_firebase ?? givenGetterValue(.p_firebase_get, "ConfigProtocolMock - stub value for firebase was not defined") } + } + private var __p_firebase: (FirebaseConfig)? + + public var facebook: FacebookConfig { + get { invocations.append(.p_facebook_get); return __p_facebook ?? givenGetterValue(.p_facebook_get, "ConfigProtocolMock - stub value for facebook was not defined") } + } + private var __p_facebook: (FacebookConfig)? + + public var microsoft: MicrosoftConfig { + get { invocations.append(.p_microsoft_get); return __p_microsoft ?? givenGetterValue(.p_microsoft_get, "ConfigProtocolMock - stub value for microsoft was not defined") } + } + private var __p_microsoft: (MicrosoftConfig)? + + public var google: GoogleConfig { + get { invocations.append(.p_google_get); return __p_google ?? givenGetterValue(.p_google_get, "ConfigProtocolMock - stub value for google was not defined") } + } + private var __p_google: (GoogleConfig)? + + public var appleSignIn: AppleSignInConfig { + get { invocations.append(.p_appleSignIn_get); return __p_appleSignIn ?? givenGetterValue(.p_appleSignIn_get, "ConfigProtocolMock - stub value for appleSignIn was not defined") } + } + private var __p_appleSignIn: (AppleSignInConfig)? + + public var features: FeaturesConfig { + get { invocations.append(.p_features_get); return __p_features ?? givenGetterValue(.p_features_get, "ConfigProtocolMock - stub value for features was not defined") } + } + private var __p_features: (FeaturesConfig)? + + public var theme: ThemeConfig { + get { invocations.append(.p_theme_get); return __p_theme ?? givenGetterValue(.p_theme_get, "ConfigProtocolMock - stub value for theme was not defined") } + } + private var __p_theme: (ThemeConfig)? + + public var uiComponents: UIComponentsConfig { + get { invocations.append(.p_uiComponents_get); return __p_uiComponents ?? givenGetterValue(.p_uiComponents_get, "ConfigProtocolMock - stub value for uiComponents was not defined") } + } + private var __p_uiComponents: (UIComponentsConfig)? + + public var discovery: DiscoveryConfig { + get { invocations.append(.p_discovery_get); return __p_discovery ?? givenGetterValue(.p_discovery_get, "ConfigProtocolMock - stub value for discovery was not defined") } + } + private var __p_discovery: (DiscoveryConfig)? + + public var dashboard: DashboardConfig { + get { invocations.append(.p_dashboard_get); return __p_dashboard ?? givenGetterValue(.p_dashboard_get, "ConfigProtocolMock - stub value for dashboard was not defined") } + } + private var __p_dashboard: (DashboardConfig)? + + public var braze: BrazeConfig { + get { invocations.append(.p_braze_get); return __p_braze ?? givenGetterValue(.p_braze_get, "ConfigProtocolMock - stub value for braze was not defined") } + } + private var __p_braze: (BrazeConfig)? + + public var branch: BranchConfig { + get { invocations.append(.p_branch_get); return __p_branch ?? givenGetterValue(.p_branch_get, "ConfigProtocolMock - stub value for branch was not defined") } + } + private var __p_branch: (BranchConfig)? + + public var segment: SegmentConfig { + get { invocations.append(.p_segment_get); return __p_segment ?? givenGetterValue(.p_segment_get, "ConfigProtocolMock - stub value for segment was not defined") } + } + private var __p_segment: (SegmentConfig)? + + public var program: DiscoveryConfig { + get { invocations.append(.p_program_get); return __p_program ?? givenGetterValue(.p_program_get, "ConfigProtocolMock - stub value for program was not defined") } + } + private var __p_program: (DiscoveryConfig)? + + public var URIScheme: String { + get { invocations.append(.p_URIScheme_get); return __p_URIScheme ?? givenGetterValue(.p_URIScheme_get, "ConfigProtocolMock - stub value for URIScheme was not defined") } + } + private var __p_URIScheme: (String)? + + public var fullStory: FullStoryConfig { + get { invocations.append(.p_fullStory_get); return __p_fullStory ?? givenGetterValue(.p_fullStory_get, "ConfigProtocolMock - stub value for fullStory was not defined") } + } + private var __p_fullStory: (FullStoryConfig)? + + + + + + + fileprivate enum MethodType { + case p_baseURL_get + case p_baseSSOURL_get + case p_ssoFinishedURL_get + case p_ssoButtonTitle_get + case p_oAuthClientId_get + case p_tokenType_get + case p_feedbackEmail_get + case p_appStoreLink_get + case p_faq_get + case p_platformName_get + case p_agreement_get + case p_firebase_get + case p_facebook_get + case p_microsoft_get + case p_google_get + case p_appleSignIn_get + case p_features_get + case p_theme_get + case p_uiComponents_get + case p_discovery_get + case p_dashboard_get + case p_braze_get + case p_branch_get + case p_segment_get + case p_program_get + case p_URIScheme_get + case p_fullStory_get + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { case (.p_baseURL_get,.p_baseURL_get): return Matcher.ComparisonResult.match + case (.p_baseSSOURL_get,.p_baseSSOURL_get): return Matcher.ComparisonResult.match + case (.p_ssoFinishedURL_get,.p_ssoFinishedURL_get): return Matcher.ComparisonResult.match + case (.p_ssoButtonTitle_get,.p_ssoButtonTitle_get): return Matcher.ComparisonResult.match + case (.p_oAuthClientId_get,.p_oAuthClientId_get): return Matcher.ComparisonResult.match + case (.p_tokenType_get,.p_tokenType_get): return Matcher.ComparisonResult.match + case (.p_feedbackEmail_get,.p_feedbackEmail_get): return Matcher.ComparisonResult.match + case (.p_appStoreLink_get,.p_appStoreLink_get): return Matcher.ComparisonResult.match + case (.p_faq_get,.p_faq_get): return Matcher.ComparisonResult.match + case (.p_platformName_get,.p_platformName_get): return Matcher.ComparisonResult.match + case (.p_agreement_get,.p_agreement_get): return Matcher.ComparisonResult.match + case (.p_firebase_get,.p_firebase_get): return Matcher.ComparisonResult.match + case (.p_facebook_get,.p_facebook_get): return Matcher.ComparisonResult.match + case (.p_microsoft_get,.p_microsoft_get): return Matcher.ComparisonResult.match + case (.p_google_get,.p_google_get): return Matcher.ComparisonResult.match + case (.p_appleSignIn_get,.p_appleSignIn_get): return Matcher.ComparisonResult.match + case (.p_features_get,.p_features_get): return Matcher.ComparisonResult.match + case (.p_theme_get,.p_theme_get): return Matcher.ComparisonResult.match + case (.p_uiComponents_get,.p_uiComponents_get): return Matcher.ComparisonResult.match + case (.p_discovery_get,.p_discovery_get): return Matcher.ComparisonResult.match + case (.p_dashboard_get,.p_dashboard_get): return Matcher.ComparisonResult.match + case (.p_braze_get,.p_braze_get): return Matcher.ComparisonResult.match + case (.p_branch_get,.p_branch_get): return Matcher.ComparisonResult.match + case (.p_segment_get,.p_segment_get): return Matcher.ComparisonResult.match + case (.p_program_get,.p_program_get): return Matcher.ComparisonResult.match + case (.p_URIScheme_get,.p_URIScheme_get): return Matcher.ComparisonResult.match + case (.p_fullStory_get,.p_fullStory_get): return Matcher.ComparisonResult.match + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .p_baseURL_get: return 0 + case .p_baseSSOURL_get: return 0 + case .p_ssoFinishedURL_get: return 0 + case .p_ssoButtonTitle_get: return 0 + case .p_oAuthClientId_get: return 0 + case .p_tokenType_get: return 0 + case .p_feedbackEmail_get: return 0 + case .p_appStoreLink_get: return 0 + case .p_faq_get: return 0 + case .p_platformName_get: return 0 + case .p_agreement_get: return 0 + case .p_firebase_get: return 0 + case .p_facebook_get: return 0 + case .p_microsoft_get: return 0 + case .p_google_get: return 0 + case .p_appleSignIn_get: return 0 + case .p_features_get: return 0 + case .p_theme_get: return 0 + case .p_uiComponents_get: return 0 + case .p_discovery_get: return 0 + case .p_dashboard_get: return 0 + case .p_braze_get: return 0 + case .p_branch_get: return 0 + case .p_segment_get: return 0 + case .p_program_get: return 0 + case .p_URIScheme_get: return 0 + case .p_fullStory_get: return 0 + } + } + func assertionName() -> String { + switch self { + case .p_baseURL_get: return "[get] .baseURL" + case .p_baseSSOURL_get: return "[get] .baseSSOURL" + case .p_ssoFinishedURL_get: return "[get] .ssoFinishedURL" + case .p_ssoButtonTitle_get: return "[get] .ssoButtonTitle" + case .p_oAuthClientId_get: return "[get] .oAuthClientId" + case .p_tokenType_get: return "[get] .tokenType" + case .p_feedbackEmail_get: return "[get] .feedbackEmail" + case .p_appStoreLink_get: return "[get] .appStoreLink" + case .p_faq_get: return "[get] .faq" + case .p_platformName_get: return "[get] .platformName" + case .p_agreement_get: return "[get] .agreement" + case .p_firebase_get: return "[get] .firebase" + case .p_facebook_get: return "[get] .facebook" + case .p_microsoft_get: return "[get] .microsoft" + case .p_google_get: return "[get] .google" + case .p_appleSignIn_get: return "[get] .appleSignIn" + case .p_features_get: return "[get] .features" + case .p_theme_get: return "[get] .theme" + case .p_uiComponents_get: return "[get] .uiComponents" + case .p_discovery_get: return "[get] .discovery" + case .p_dashboard_get: return "[get] .dashboard" + case .p_braze_get: return "[get] .braze" + case .p_branch_get: return "[get] .branch" + case .p_segment_get: return "[get] .segment" + case .p_program_get: return "[get] .program" + case .p_URIScheme_get: return "[get] .URIScheme" + case .p_fullStory_get: return "[get] .fullStory" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func baseURL(getter defaultValue: URL...) -> PropertyStub { + return Given(method: .p_baseURL_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func baseSSOURL(getter defaultValue: URL...) -> PropertyStub { + return Given(method: .p_baseSSOURL_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func ssoFinishedURL(getter defaultValue: URL...) -> PropertyStub { + return Given(method: .p_ssoFinishedURL_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func ssoButtonTitle(getter defaultValue: [String: Any]...) -> PropertyStub { + return Given(method: .p_ssoButtonTitle_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func oAuthClientId(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_oAuthClientId_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func tokenType(getter defaultValue: TokenType...) -> PropertyStub { + return Given(method: .p_tokenType_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func feedbackEmail(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_feedbackEmail_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appStoreLink(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_appStoreLink_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func faq(getter defaultValue: URL?...) -> PropertyStub { + return Given(method: .p_faq_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func platformName(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_platformName_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func agreement(getter defaultValue: AgreementConfig...) -> PropertyStub { + return Given(method: .p_agreement_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func firebase(getter defaultValue: FirebaseConfig...) -> PropertyStub { + return Given(method: .p_firebase_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func facebook(getter defaultValue: FacebookConfig...) -> PropertyStub { + return Given(method: .p_facebook_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func microsoft(getter defaultValue: MicrosoftConfig...) -> PropertyStub { + return Given(method: .p_microsoft_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func google(getter defaultValue: GoogleConfig...) -> PropertyStub { + return Given(method: .p_google_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appleSignIn(getter defaultValue: AppleSignInConfig...) -> PropertyStub { + return Given(method: .p_appleSignIn_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func features(getter defaultValue: FeaturesConfig...) -> PropertyStub { + return Given(method: .p_features_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func theme(getter defaultValue: ThemeConfig...) -> PropertyStub { + return Given(method: .p_theme_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func uiComponents(getter defaultValue: UIComponentsConfig...) -> PropertyStub { + return Given(method: .p_uiComponents_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func discovery(getter defaultValue: DiscoveryConfig...) -> PropertyStub { + return Given(method: .p_discovery_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func dashboard(getter defaultValue: DashboardConfig...) -> PropertyStub { + return Given(method: .p_dashboard_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func braze(getter defaultValue: BrazeConfig...) -> PropertyStub { + return Given(method: .p_braze_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func branch(getter defaultValue: BranchConfig...) -> PropertyStub { + return Given(method: .p_branch_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func segment(getter defaultValue: SegmentConfig...) -> PropertyStub { + return Given(method: .p_segment_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func program(getter defaultValue: DiscoveryConfig...) -> PropertyStub { + return Given(method: .p_program_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func URIScheme(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_URIScheme_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func fullStory(getter defaultValue: FullStoryConfig...) -> PropertyStub { + return Given(method: .p_fullStory_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static var baseURL: Verify { return Verify(method: .p_baseURL_get) } + public static var baseSSOURL: Verify { return Verify(method: .p_baseSSOURL_get) } + public static var ssoFinishedURL: Verify { return Verify(method: .p_ssoFinishedURL_get) } + public static var ssoButtonTitle: Verify { return Verify(method: .p_ssoButtonTitle_get) } + public static var oAuthClientId: Verify { return Verify(method: .p_oAuthClientId_get) } + public static var tokenType: Verify { return Verify(method: .p_tokenType_get) } + public static var feedbackEmail: Verify { return Verify(method: .p_feedbackEmail_get) } + public static var appStoreLink: Verify { return Verify(method: .p_appStoreLink_get) } + public static var faq: Verify { return Verify(method: .p_faq_get) } + public static var platformName: Verify { return Verify(method: .p_platformName_get) } + public static var agreement: Verify { return Verify(method: .p_agreement_get) } + public static var firebase: Verify { return Verify(method: .p_firebase_get) } + public static var facebook: Verify { return Verify(method: .p_facebook_get) } + public static var microsoft: Verify { return Verify(method: .p_microsoft_get) } + public static var google: Verify { return Verify(method: .p_google_get) } + public static var appleSignIn: Verify { return Verify(method: .p_appleSignIn_get) } + public static var features: Verify { return Verify(method: .p_features_get) } + public static var theme: Verify { return Verify(method: .p_theme_get) } + public static var uiComponents: Verify { return Verify(method: .p_uiComponents_get) } + public static var discovery: Verify { return Verify(method: .p_discovery_get) } + public static var dashboard: Verify { return Verify(method: .p_dashboard_get) } + public static var braze: Verify { return Verify(method: .p_braze_get) } + public static var branch: Verify { return Verify(method: .p_branch_get) } + public static var segment: Verify { return Verify(method: .p_segment_get) } + public static var program: Verify { return Verify(method: .p_program_get) } + public static var URIScheme: Verify { return Verify(method: .p_URIScheme_get) } + public static var fullStory: Verify { return Verify(method: .p_fullStory_get) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + } + + 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: - ConnectivityProtocol open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { diff --git a/Profile/Profile.xcodeproj/project.pbxproj b/Profile/Profile.xcodeproj/project.pbxproj index 1562e19f0..cdced1def 100644 --- a/Profile/Profile.xcodeproj/project.pbxproj +++ b/Profile/Profile.xcodeproj/project.pbxproj @@ -58,6 +58,8 @@ 0796C8C929B7905300444B05 /* ProfileBottomSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0796C8C829B7905300444B05 /* ProfileBottomSheet.swift */; }; 25B36FF48C1307888A3890DA /* Pods_App_Profile.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = BEA369C38362C1A91A012F70 /* Pods_App_Profile.framework */; }; BAD9CA3F2B29BF5C00DE790A /* ProfileSupportInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA3E2B29BF5C00DE790A /* ProfileSupportInfoView.swift */; }; + CE1735042CD23D7A00F9606A /* DatesAndCalendarViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1735032CD23D7A00F9606A /* DatesAndCalendarViewModelTests.swift */; }; + CE961F032CD163FD00799B9F /* CalendarManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE961F022CD163FD00799B9F /* CalendarManagerTests.swift */; }; E8264C634DD8AD314ECE8905 /* Pods_App_Profile_ProfileTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C85ADF87135E03275A980E07 /* Pods_App_Profile_ProfileTests.framework */; }; /* End PBXBuildFile section */ @@ -139,6 +141,8 @@ BAD9CA3E2B29BF5C00DE790A /* ProfileSupportInfoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileSupportInfoView.swift; sourceTree = ""; }; BEA369C38362C1A91A012F70 /* Pods_App_Profile.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_Profile.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C85ADF87135E03275A980E07 /* Pods_App_Profile_ProfileTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_Profile_ProfileTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + CE1735032CD23D7A00F9606A /* DatesAndCalendarViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DatesAndCalendarViewModelTests.swift; sourceTree = ""; }; + CE961F022CD163FD00799B9F /* CalendarManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarManagerTests.swift; sourceTree = ""; }; F52EFE7DC07BE68B9A302DAF /* Pods-App-Profile.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Profile.debug.xcconfig"; path = "Target Support Files/Pods-App-Profile/Pods-App-Profile.debug.xcconfig"; sourceTree = ""; }; FB33709D5DBACDEA33BD016F /* Pods-App-Profile-ProfileTests.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Profile-ProfileTests.debugstage.xcconfig"; path = "Target Support Files/Pods-App-Profile-ProfileTests/Pods-App-Profile-ProfileTests.debugstage.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ @@ -375,6 +379,8 @@ 02A9A91B2978194A00B55797 /* ProfileTests */ = { isa = PBXGroup; children = ( + CE961F022CD163FD00799B9F /* CalendarManagerTests.swift */, + CE1735032CD23D7A00F9606A /* DatesAndCalendarViewModelTests.swift */, 0766DFD3299AD9D800EBEF6A /* Presentation */, 02A9A92A29781A6300B55797 /* ProfileMock.generated.swift */, ); @@ -706,8 +712,10 @@ buildActionMask = 2147483647; files = ( 02A9A92B29781A6300B55797 /* ProfileMock.generated.swift in Sources */, + CE1735042CD23D7A00F9606A /* DatesAndCalendarViewModelTests.swift in Sources */, 02A4833329B7710A00D33F33 /* DeleteAccountViewModelTests.swift in Sources */, 02FE9A802BC707D500B3C206 /* SettingsViewModelTests.swift in Sources */, + CE961F032CD163FD00799B9F /* CalendarManagerTests.swift in Sources */, 02A9A91D2978194A00B55797 /* ProfileViewModelTests.swift in Sources */, 020102D129784B3100BBF80C /* EditProfileViewModelTests.swift in Sources */, ); diff --git a/Profile/Profile/Data/Persistence/ProfilePersistenceProtocol.swift b/Profile/Profile/Data/Persistence/ProfilePersistenceProtocol.swift index 3e26799bd..e87f1aa19 100644 --- a/Profile/Profile/Data/Persistence/ProfilePersistenceProtocol.swift +++ b/Profile/Profile/Data/Persistence/ProfilePersistenceProtocol.swift @@ -8,6 +8,7 @@ import CoreData import Core +//sourcery: AutoMockable public protocol ProfilePersistenceProtocol { func getCourseState(courseID: String) -> CourseCalendarState? func getAllCourseStates() -> [CourseCalendarState] diff --git a/Profile/Profile/Data/ProfileStorage.swift b/Profile/Profile/Data/ProfileStorage.swift index 88e8fe48b..68347091f 100644 --- a/Profile/Profile/Data/ProfileStorage.swift +++ b/Profile/Profile/Data/ProfileStorage.swift @@ -9,6 +9,7 @@ import Foundation import Core import UIKit +//sourcery: AutoMockable public protocol ProfileStorage { var userProfile: DataLayer.UserProfile? {get set} var useRelativeDates: Bool {get set} diff --git a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift index 66be4d491..3201e2a1f 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift @@ -39,8 +39,8 @@ public class DatesAndCalendarViewModel: ObservableObject { private var coursesForSyncBeforeChanges = [CourseForSync]() - private var coursesForDeleting = [CourseForSync]() - private var coursesForAdding = [CourseForSync]() + private(set) var coursesForDeleting = [CourseForSync]() + private(set) var coursesForAdding = [CourseForSync]() @Published var synced: Bool = true @Published var hideInactiveCourses: Bool = false diff --git a/Profile/ProfileTests/CalendarManagerTests.swift b/Profile/ProfileTests/CalendarManagerTests.swift new file mode 100644 index 000000000..de245eaea --- /dev/null +++ b/Profile/ProfileTests/CalendarManagerTests.swift @@ -0,0 +1,290 @@ +// +// CalendarManagerTests.swift +// Profile +// +// Created by Ivan Stepanok on 29.10.2024. +// + + +import SwiftyMocky +import XCTest +import EventKit +@testable import Profile +@testable import Core +import Theme +import SwiftUICore + +final class CalendarManagerTests: XCTestCase { + + func testCourseStatusSynced() { + let persistence = ProfilePersistenceProtocolMock() + let interactor = ProfileInteractorProtocolMock() + let profileStorage = ProfileStorageMock() + + let manager = CalendarManager( + persistence: persistence, + interactor: interactor, + profileStorage: profileStorage + ) + + let states = [CourseCalendarState(courseID: "course-1", checksum: "checksum-1")] + Given(persistence, .getAllCourseStates(willReturn: states)) + + let status = manager.courseStatus(courseID: "course-1") + + Verify(persistence, 1, .getAllCourseStates()) + XCTAssertEqual(status, .synced) + } + + func testCourseStatusOffline() { + let persistence = ProfilePersistenceProtocolMock() + let interactor = ProfileInteractorProtocolMock() + let profileStorage = ProfileStorageMock() + + let manager = CalendarManager( + persistence: persistence, + interactor: interactor, + profileStorage: profileStorage + ) + + let states = [CourseCalendarState(courseID: "course-2", checksum: "checksum-2")] + Given(persistence, .getAllCourseStates(willReturn: states)) + + let status = manager.courseStatus(courseID: "course-1") + + Verify(persistence, 1, .getAllCourseStates()) + XCTAssertEqual(status, .offline) + } + + func testIsDatesChanged() { + let persistence = ProfilePersistenceProtocolMock() + let interactor = ProfileInteractorProtocolMock() + let profileStorage = ProfileStorageMock() + + let manager = CalendarManager( + persistence: persistence, + interactor: interactor, + profileStorage: profileStorage + ) + + let state = CourseCalendarState(courseID: "course-1", checksum: "old-checksum") + Given(persistence, .getCourseState(courseID: .value("course-1"), willReturn: state)) + + let changed = manager.isDatesChanged(courseID: "course-1", checksum: "new-checksum") + + Verify(persistence, 1, .getCourseState(courseID: .value("course-1"))) + XCTAssertTrue(changed) + } + + func testIsDatesNotChanged() { + let persistence = ProfilePersistenceProtocolMock() + let interactor = ProfileInteractorProtocolMock() + let profileStorage = ProfileStorageMock() + + let manager = CalendarManager( + persistence: persistence, + interactor: interactor, + profileStorage: profileStorage + ) + + let state = CourseCalendarState(courseID: "course-1", checksum: "same-checksum") + Given(persistence, .getCourseState(courseID: .value("course-1"), willReturn: state)) + + let changed = manager.isDatesChanged(courseID: "course-1", checksum: "same-checksum") + + Verify(persistence, 1, .getCourseState(courseID: .value("course-1"))) + XCTAssertFalse(changed) + } + + func testClearAllData() { + let persistence = ProfilePersistenceProtocolMock() + let interactor = ProfileInteractorProtocolMock() + let profileStorage = ProfileStorageMock() + + // Setup initial values + profileStorage.firstCalendarUpdate = true + profileStorage.hideInactiveCourses = true + profileStorage.lastCalendarName = "Test Calendar" + profileStorage.calendarSettings = CalendarSettings( + colorSelection: "accent", + calendarName: "Test Calendar", + accountSelection: "iCloud", + courseCalendarSync: true + ) + profileStorage.lastCalendarUpdateDate = Date() + + // Verify initial values are set + XCTAssertTrue(profileStorage.firstCalendarUpdate ?? false) + XCTAssertTrue(profileStorage.hideInactiveCourses ?? false) + XCTAssertNotNil(profileStorage.lastCalendarName) + XCTAssertNotNil(profileStorage.calendarSettings) + XCTAssertNotNil(profileStorage.lastCalendarUpdateDate) + + let manager = CalendarManager( + persistence: persistence, + interactor: interactor, + profileStorage: profileStorage + ) + + manager.clearAllData(removeCalendar: true) + + // Verify persistence method was called + Verify(persistence, 1, .deleteAllCourseStatesAndEvents()) + + // Verify all values were cleared + XCTAssertEqual(profileStorage.firstCalendarUpdate, false) + XCTAssertNil(profileStorage.hideInactiveCourses) + XCTAssertNil(profileStorage.lastCalendarName) + XCTAssertNil(profileStorage.calendarSettings) + XCTAssertNil(profileStorage.lastCalendarUpdateDate) + } + + func testFilterCoursesBySelected() async throws { + let persistence = ProfilePersistenceProtocolMock() + let interactor = ProfileInteractorProtocolMock() + let profileStorage = ProfileStorageMock() + + let manager = CalendarManager( + persistence: persistence, + interactor: interactor, + profileStorage: profileStorage + ) + + let states = [ + CourseCalendarState(courseID: "course-1", checksum: "checksum-1"), + CourseCalendarState(courseID: "course-2", checksum: "checksum-2"), + CourseCalendarState(courseID: "course-3", checksum: "checksum-3") + ] + + let fetchedCourses = [ + CourseForSync( + id: UUID(), + courseID: "course-1", + name: "Course 1", + synced: true, + recentlyActive: true + ), + CourseForSync( + id: UUID(), + courseID: "course-2", + name: "Course 2", + synced: true, + recentlyActive: false + ), + CourseForSync( + id: UUID(), + courseID: "course-4", + name: "Course 4", + synced: false, + recentlyActive: true + ) + ] + + // Setup mocks + Given(persistence, .getAllCourseStates(willReturn: states)) + Given(persistence, .getCourseCalendarEvents(for: .any, willReturn: [])) + Given(persistence, .getCourseState(courseID: .any, willReturn: nil)) +// Given(persistence, .removeCourseCalendarEvents(for: .any, willProduce: { _ in })) + + // Execute filtering + let filteredCourses = await manager.filterCoursesBySelected(fetchedCourses: fetchedCourses) + + // Verify calls + Verify(persistence, 1, .getAllCourseStates()) + + // Verify course-3 was removed (exists in states but not in fetched) + Verify(persistence, 1, .getCourseCalendarEvents(for: .value("course-3"))) + Verify(persistence, 1, .removeCourseCalendarEvents(for: .value("course-3"))) + + // Verify course-2 was removed (inactive) + Verify(persistence, 1, .getCourseCalendarEvents(for: .value("course-2"))) + Verify(persistence, 1, .removeCourseCalendarEvents(for: .value("course-2"))) + + // Verify results + XCTAssertEqual(filteredCourses.count, 1) + XCTAssertEqual(filteredCourses.first?.courseID, "course-1") + XCTAssertEqual(filteredCourses.first?.name, "Course 1") + XCTAssertTrue(filteredCourses.first?.synced ?? false) + XCTAssertTrue(filteredCourses.first?.recentlyActive ?? false) + } + + func testFilterCoursesBySelectedEmptyStates() async { + let persistence = ProfilePersistenceProtocolMock() + let interactor = ProfileInteractorProtocolMock() + let profileStorage = ProfileStorageMock() + + let manager = CalendarManager( + persistence: persistence, + interactor: interactor, + profileStorage: profileStorage + ) + + Given(persistence, .getAllCourseStates(willReturn: [])) + + let fetchedCourses = [ + CourseForSync( + id: UUID(), + courseID: "course-1", + name: "Course 1", + synced: true, + recentlyActive: true + ), + CourseForSync( + id: UUID(), + courseID: "course-2", + name: "Course 2", + synced: true, + recentlyActive: false + ) + ] + + let filteredCourses = await manager.filterCoursesBySelected(fetchedCourses: fetchedCourses) + + Verify(persistence, 1, .getAllCourseStates()) + XCTAssertEqual(filteredCourses, fetchedCourses) + } + + func testCalendarNameFromSettings() { + let persistence = ProfilePersistenceProtocolMock() + let interactor = ProfileInteractorProtocolMock() + let profileStorage = ProfileStorageMock() + + let settings = CalendarSettings( + colorSelection: "accent", + calendarName: "Test Calendar", + accountSelection: "iCloud", + courseCalendarSync: true + ) + Given(profileStorage, .calendarSettings(getter: settings)) + + let manager = CalendarManager( + persistence: persistence, + interactor: interactor, + profileStorage: profileStorage + ) + + XCTAssertEqual(manager.calendarName, "Test Calendar") + } + + func testColorSelectionFromSettings() { + let persistence = ProfilePersistenceProtocolMock() + let interactor = ProfileInteractorProtocolMock() + let profileStorage = ProfileStorageMock() + + let settings = CalendarSettings( + colorSelection: "accent", + calendarName: "Test Calendar", + accountSelection: "iCloud", + courseCalendarSync: true + ) + Given(profileStorage, .calendarSettings(getter: settings)) + + let manager = CalendarManager( + persistence: persistence, + interactor: interactor, + profileStorage: profileStorage + ) + + XCTAssertEqual(manager.colorSelection?.color, Color.accentColor) + } +} diff --git a/Profile/ProfileTests/DatesAndCalendarViewModelTests.swift b/Profile/ProfileTests/DatesAndCalendarViewModelTests.swift new file mode 100644 index 000000000..443dcf875 --- /dev/null +++ b/Profile/ProfileTests/DatesAndCalendarViewModelTests.swift @@ -0,0 +1,328 @@ +// +// DatesAndCalendarViewModelTests.swift +// Profile +// +// Created by Ivan Stepanok on 30.10.2024. +// + + +import SwiftyMocky +import XCTest +import EventKit +@testable import Profile +@testable import Core +import Theme +import SwiftUICore +import Combine + +final class DatesAndCalendarViewModelTests: XCTestCase { + + var cancellables: Set! + + override func setUp() { + super.setUp() + cancellables = [] + } + + func testLoadCalendarOptions() { + // Given + let router = ProfileRouterMock() + let interactor = ProfileInteractorProtocolMock() + let persistence = ProfilePersistenceProtocolMock() + let calendarManager = CalendarManagerProtocolMock() + let connectivity = ConnectivityProtocolMock() + let profileStorage = ProfileStorageMock() + + let settings = CalendarSettings( + colorSelection: "accent", + calendarName: "Test Calendar", + accountSelection: "iCloud", + courseCalendarSync: true + ) + Given(profileStorage, .calendarSettings(getter: settings)) + Given(profileStorage, .lastCalendarName(getter: "Old Calendar")) + Given(profileStorage, .hideInactiveCourses(getter: true)) + + let viewModel = DatesAndCalendarViewModel( + router: router, + interactor: interactor, + profileStorage: profileStorage, + persistence: persistence, + calendarManager: calendarManager, + connectivity: connectivity + ) + + // When + viewModel.loadCalendarOptions() + + // Then + XCTAssertEqual(viewModel.colorSelection?.colorString, "accent") + XCTAssertEqual(viewModel.accountSelection?.title, "iCloud") + XCTAssertEqual(viewModel.calendarName, "Test Calendar") + XCTAssertEqual(viewModel.oldCalendarName, "Old Calendar") + XCTAssertTrue(viewModel.courseCalendarSync) + XCTAssertTrue(viewModel.hideInactiveCourses) + } + + func testClearAllData() { + // Given + let router = ProfileRouterMock() + let interactor = ProfileInteractorProtocolMock() + let persistence = ProfilePersistenceProtocolMock() + let calendarManager = CalendarManagerProtocolMock() + let connectivity = ConnectivityProtocolMock() + let profileStorage = ProfileStorageMock() + + let viewModel = DatesAndCalendarViewModel( + router: router, + interactor: interactor, + profileStorage: profileStorage, + persistence: persistence, + calendarManager: calendarManager, + connectivity: connectivity + ) + + // When + viewModel.clearAllData() + + // Then + Verify(calendarManager, 1, .clearAllData(removeCalendar: .value(true))) + Verify(router, 1, .back(animated: .value(false))) + Verify(router, 1, .showDatesAndCalendar()) + XCTAssertTrue(viewModel.courseCalendarSync) + XCTAssertFalse(viewModel.showDisableCalendarSync) + XCTAssertFalse(viewModel.openNewCalendarView) + } + + func testSaveCalendarOptions() { + // Given + let router = ProfileRouterMock() + let interactor = ProfileInteractorProtocolMock() + let persistence = ProfilePersistenceProtocolMock() + let calendarManager = CalendarManagerProtocolMock() + let connectivity = ConnectivityProtocolMock() + let profileStorage = ProfileStorageMock() + + var settings = CalendarSettings( + colorSelection: "accent", + calendarName: "Old Calendar", + accountSelection: "iCloud", + courseCalendarSync: true + ) + Given(profileStorage, .calendarSettings(getter: settings)) + + let viewModel = DatesAndCalendarViewModel( + router: router, + interactor: interactor, + profileStorage: profileStorage, + persistence: persistence, + calendarManager: calendarManager, + connectivity: connectivity + ) + + // When + viewModel.calendarName = "New Calendar" + viewModel.colorSelection = .init(color: .red) + viewModel.accountSelection = .init(title: "Local") + viewModel.courseCalendarSync = false + viewModel.saveCalendarOptions() + + // Then + XCTAssertEqual(profileStorage.calendarSettings?.calendarName, "New Calendar") + XCTAssertEqual(profileStorage.calendarSettings?.colorSelection, "red") + XCTAssertEqual(profileStorage.calendarSettings?.accountSelection, "Local") + XCTAssertFalse(profileStorage.calendarSettings?.courseCalendarSync ?? true) + XCTAssertEqual(profileStorage.lastCalendarName, "New Calendar") + } + + func testFetchCoursesSuccess() async { + // Given + let router = ProfileRouterMock() + let interactor = ProfileInteractorProtocolMock() + let persistence = ProfilePersistenceProtocolMock() + let calendarManager = CalendarManagerProtocolMock() + let connectivity = ConnectivityProtocolMock() + let profileStorage = ProfileStorageMock() + + Given(connectivity, .isInternetAvaliable(getter: true)) + Given(calendarManager, .requestAccess(willReturn: true)) + + let courses = [ + CourseForSync( + id: UUID(), + courseID: "course-1", + name: "Course 1", + synced: true, + recentlyActive: true + ) + ] + Given(interactor, .enrollmentsStatus(willReturn: courses)) + Given(persistence, .getAllCourseStates(willReturn: [])) + + let viewModel = DatesAndCalendarViewModel( + router: router, + interactor: interactor, + profileStorage: profileStorage, + persistence: persistence, + calendarManager: calendarManager, + connectivity: connectivity + ) + + // When + await viewModel.fetchCourses() + + // Then + XCTAssertEqual(viewModel.assignmentStatus, .synced) + XCTAssertEqual(viewModel.coursesForSync.count, 1) + XCTAssertEqual(viewModel.coursesForSync.first?.courseID, "course-1") + Verify(calendarManager, 1, .createCalendarIfNeeded()) + Verify(interactor, 1, .enrollmentsStatus()) + } + + func testRequestCalendarPermissionSuccess() async { + // Given + let router = ProfileRouterMock() + let interactor = ProfileInteractorProtocolMock() + let persistence = ProfilePersistenceProtocolMock() + let calendarManager = CalendarManagerProtocolMock() + let connectivity = ConnectivityProtocolMock() + let profileStorage = ProfileStorageMock() + + Given(calendarManager, .requestAccess(willReturn: true)) + + let viewModel = DatesAndCalendarViewModel( + router: router, + interactor: interactor, + profileStorage: profileStorage, + persistence: persistence, + calendarManager: calendarManager, + connectivity: connectivity + ) + + // When + await viewModel.requestCalendarPermission() + + // Then + XCTAssertTrue(viewModel.openNewCalendarView) + XCTAssertFalse(viewModel.showCalendaAccessDenied) + } + + func testRequestCalendarPermissionDenied() async { + // Given + let router = ProfileRouterMock() + let interactor = ProfileInteractorProtocolMock() + let persistence = ProfilePersistenceProtocolMock() + let calendarManager = CalendarManagerProtocolMock() + let connectivity = ConnectivityProtocolMock() + let profileStorage = ProfileStorageMock() + + Given(calendarManager, .requestAccess(willReturn: false)) + + let viewModel = DatesAndCalendarViewModel( + router: router, + interactor: interactor, + profileStorage: profileStorage, + persistence: persistence, + calendarManager: calendarManager, + connectivity: connectivity + ) + + // When + await viewModel.requestCalendarPermission() + + // Then + XCTAssertTrue(viewModel.showCalendaAccessDenied) + XCTAssertFalse(viewModel.openNewCalendarView) + } + + func testToggleSyncForCourse() { + // Given + let router = ProfileRouterMock() + let interactor = ProfileInteractorProtocolMock() + let persistence = ProfilePersistenceProtocolMock() + let calendarManager = CalendarManagerProtocolMock() + let connectivity = ConnectivityProtocolMock() + let profileStorage = ProfileStorageMock() + + let course = CourseForSync( + id: UUID(), + courseID: "course-1", + name: "Course 1", + synced: false, + recentlyActive: true + ) + + let viewModel = DatesAndCalendarViewModel( + router: router, + interactor: interactor, + profileStorage: profileStorage, + persistence: persistence, + calendarManager: calendarManager, + connectivity: connectivity + ) + viewModel.coursesForSync = [course] + + // When + viewModel.toggleSync(for: course) + + // Then + XCTAssertTrue(viewModel.coursesForSync.first?.synced ?? false) + XCTAssertEqual(viewModel.coursesForAdding.count, 1) + XCTAssertEqual(viewModel.coursesForAdding.first?.courseID, "course-1") + } + + func testDeleteOldCalendarIfNeeded() async { + // Given + let router = ProfileRouterMock() + let interactor = ProfileInteractorProtocolMock() + let persistence = ProfilePersistenceProtocolMock() + let calendarManager = CalendarManagerProtocolMock() + let connectivity = ConnectivityProtocolMock() + let profileStorage = ProfileStorageMock() + + let settings = CalendarSettings( + colorSelection: "accent", + calendarName: "Old Calendar", + accountSelection: "iCloud", + courseCalendarSync: true + ) + + let states = [ + CourseCalendarState(courseID: "123", checksum: "checksum"), + CourseCalendarState(courseID: "124", checksum: "checksum2") + ] + + Given(persistence, .getAllCourseStates(willReturn: states)) + Given(profileStorage, .calendarSettings(getter: settings)) + Given(connectivity, .isInternetAvaliable(getter: true)) + Given(calendarManager, .requestAccess(willReturn: true)) + + let courses = [ + CourseForSync( + id: UUID(), + courseID: "course-1", + name: "Course 1", + synced: true, + recentlyActive: true + ) + ] + Given(interactor, .enrollmentsStatus(willReturn: courses)) + + let viewModel = DatesAndCalendarViewModel( + router: router, + interactor: interactor, + profileStorage: profileStorage, + persistence: persistence, + calendarManager: calendarManager, + connectivity: connectivity + ) + viewModel.calendarName = "New Calendar" + + // When + await viewModel.deleteOldCalendarIfNeeded() + + // Then + Verify(calendarManager, 1, .removeOldCalendar()) + Verify(persistence, 1, .removeAllCourseCalendarEvents()) + } +} diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index f6d738dff..6a2424da6 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -978,9 +978,9 @@ open class BaseRouterMock: BaseRouter, Mock { } } -// MARK: - ConnectivityProtocol +// MARK: - CalendarManagerProtocol -open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { +open class CalendarManagerProtocolMock: CalendarManagerProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() self.sequencingPolicy = sequencingPolicy @@ -1018,51 +1018,176 @@ open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { if scopes.contains(.perform) { methodPerformValues = [] } } - public var isInternetAvaliable: Bool { - get { invocations.append(.p_isInternetAvaliable_get); return __p_isInternetAvaliable ?? givenGetterValue(.p_isInternetAvaliable_get, "ConnectivityProtocolMock - stub value for isInternetAvaliable was not defined") } - } - private var __p_isInternetAvaliable: (Bool)? - public var isMobileData: Bool { - get { invocations.append(.p_isMobileData_get); return __p_isMobileData ?? givenGetterValue(.p_isMobileData_get, "ConnectivityProtocolMock - stub value for isMobileData was not defined") } - } - private var __p_isMobileData: (Bool)? - public var internetReachableSubject: CurrentValueSubject { - get { invocations.append(.p_internetReachableSubject_get); return __p_internetReachableSubject ?? givenGetterValue(.p_internetReachableSubject_get, "ConnectivityProtocolMock - stub value for internetReachableSubject was not defined") } - } - private var __p_internetReachableSubject: (CurrentValueSubject)? + open func createCalendarIfNeeded() { + addInvocation(.m_createCalendarIfNeeded) + let perform = methodPerformValue(.m_createCalendarIfNeeded) as? () -> Void + perform?() + } + + open func filterCoursesBySelected(fetchedCourses: [CourseForSync]) -> [CourseForSync] { + addInvocation(.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>.value(`fetchedCourses`))) + let perform = methodPerformValue(.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>.value(`fetchedCourses`))) as? ([CourseForSync]) -> Void + perform?(`fetchedCourses`) + var __value: [CourseForSync] + do { + __value = try methodReturnValue(.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>.value(`fetchedCourses`))).casted() + } catch { + onFatalFailure("Stub return value not specified for filterCoursesBySelected(fetchedCourses: [CourseForSync]). Use given") + Failure("Stub return value not specified for filterCoursesBySelected(fetchedCourses: [CourseForSync]). Use given") + } + return __value + } + + open func removeOldCalendar() { + addInvocation(.m_removeOldCalendar) + let perform = methodPerformValue(.m_removeOldCalendar) as? () -> Void + perform?() + } + + open func removeOutdatedEvents(courseID: String) { + addInvocation(.m_removeOutdatedEvents__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_removeOutdatedEvents__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + } + + open func syncCourse(courseID: String, courseName: String, dates: CourseDates) { + addInvocation(.m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(Parameter.value(`courseID`), Parameter.value(`courseName`), Parameter.value(`dates`))) + let perform = methodPerformValue(.m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(Parameter.value(`courseID`), Parameter.value(`courseName`), Parameter.value(`dates`))) as? (String, String, CourseDates) -> Void + perform?(`courseID`, `courseName`, `dates`) + } + + open func requestAccess() -> Bool { + addInvocation(.m_requestAccess) + let perform = methodPerformValue(.m_requestAccess) as? () -> Void + perform?() + var __value: Bool + do { + __value = try methodReturnValue(.m_requestAccess).casted() + } catch { + onFatalFailure("Stub return value not specified for requestAccess(). Use given") + Failure("Stub return value not specified for requestAccess(). Use given") + } + return __value + } + + open func courseStatus(courseID: String) -> SyncStatus { + addInvocation(.m_courseStatus__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_courseStatus__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + var __value: SyncStatus + do { + __value = try methodReturnValue(.m_courseStatus__courseID_courseID(Parameter.value(`courseID`))).casted() + } catch { + onFatalFailure("Stub return value not specified for courseStatus(courseID: String). Use given") + Failure("Stub return value not specified for courseStatus(courseID: String). Use given") + } + return __value + } + open func clearAllData(removeCalendar: Bool) { + addInvocation(.m_clearAllData__removeCalendar_removeCalendar(Parameter.value(`removeCalendar`))) + let perform = methodPerformValue(.m_clearAllData__removeCalendar_removeCalendar(Parameter.value(`removeCalendar`))) as? (Bool) -> Void + perform?(`removeCalendar`) + } + open func isDatesChanged(courseID: String, checksum: String) -> Bool { + addInvocation(.m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter.value(`courseID`), Parameter.value(`checksum`))) + let perform = methodPerformValue(.m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter.value(`courseID`), Parameter.value(`checksum`))) as? (String, String) -> Void + perform?(`courseID`, `checksum`) + var __value: Bool + do { + __value = try methodReturnValue(.m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter.value(`courseID`), Parameter.value(`checksum`))).casted() + } catch { + onFatalFailure("Stub return value not specified for isDatesChanged(courseID: String, checksum: String). Use given") + Failure("Stub return value not specified for isDatesChanged(courseID: String, checksum: String). Use given") + } + return __value + } fileprivate enum MethodType { - case p_isInternetAvaliable_get - case p_isMobileData_get - case p_internetReachableSubject_get + case m_createCalendarIfNeeded + case m_filterCoursesBySelected__fetchedCourses_fetchedCourses(Parameter<[CourseForSync]>) + case m_removeOldCalendar + case m_removeOutdatedEvents__courseID_courseID(Parameter) + case m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(Parameter, Parameter, Parameter) + case m_requestAccess + case m_courseStatus__courseID_courseID(Parameter) + case m_clearAllData__removeCalendar_removeCalendar(Parameter) + case m_isDatesChanged__courseID_courseIDchecksum_checksum(Parameter, Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { - switch (lhs, rhs) { case (.p_isInternetAvaliable_get,.p_isInternetAvaliable_get): return Matcher.ComparisonResult.match - case (.p_isMobileData_get,.p_isMobileData_get): return Matcher.ComparisonResult.match - case (.p_internetReachableSubject_get,.p_internetReachableSubject_get): return Matcher.ComparisonResult.match + switch (lhs, rhs) { + case (.m_createCalendarIfNeeded, .m_createCalendarIfNeeded): return .match + + case (.m_filterCoursesBySelected__fetchedCourses_fetchedCourses(let lhsFetchedcourses), .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(let rhsFetchedcourses)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsFetchedcourses, rhs: rhsFetchedcourses, with: matcher), lhsFetchedcourses, rhsFetchedcourses, "fetchedCourses")) + return Matcher.ComparisonResult(results) + + case (.m_removeOldCalendar, .m_removeOldCalendar): return .match + + case (.m_removeOutdatedEvents__courseID_courseID(let lhsCourseid), .m_removeOutdatedEvents__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_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(let lhsCourseid, let lhsCoursename, let lhsDates), .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(let rhsCourseid, let rhsCoursename, let rhsDates)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCoursename, rhs: rhsCoursename, with: matcher), lhsCoursename, rhsCoursename, "courseName")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDates, rhs: rhsDates, with: matcher), lhsDates, rhsDates, "dates")) + return Matcher.ComparisonResult(results) + + case (.m_requestAccess, .m_requestAccess): return .match + + case (.m_courseStatus__courseID_courseID(let lhsCourseid), .m_courseStatus__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_clearAllData__removeCalendar_removeCalendar(let lhsRemovecalendar), .m_clearAllData__removeCalendar_removeCalendar(let rhsRemovecalendar)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsRemovecalendar, rhs: rhsRemovecalendar, with: matcher), lhsRemovecalendar, rhsRemovecalendar, "removeCalendar")) + return Matcher.ComparisonResult(results) + + case (.m_isDatesChanged__courseID_courseIDchecksum_checksum(let lhsCourseid, let lhsChecksum), .m_isDatesChanged__courseID_courseIDchecksum_checksum(let rhsCourseid, let rhsChecksum)): + 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: lhsChecksum, rhs: rhsChecksum, with: matcher), lhsChecksum, rhsChecksum, "checksum")) + return Matcher.ComparisonResult(results) default: return .none } } func intValue() -> Int { switch self { - case .p_isInternetAvaliable_get: return 0 - case .p_isMobileData_get: return 0 - case .p_internetReachableSubject_get: return 0 + case .m_createCalendarIfNeeded: return 0 + case let .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(p0): return p0.intValue + case .m_removeOldCalendar: return 0 + case let .m_removeOutdatedEvents__courseID_courseID(p0): return p0.intValue + case let .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case .m_requestAccess: return 0 + case let .m_courseStatus__courseID_courseID(p0): return p0.intValue + case let .m_clearAllData__removeCalendar_removeCalendar(p0): return p0.intValue + case let .m_isDatesChanged__courseID_courseIDchecksum_checksum(p0, p1): return p0.intValue + p1.intValue } } func assertionName() -> String { switch self { - case .p_isInternetAvaliable_get: return "[get] .isInternetAvaliable" - case .p_isMobileData_get: return "[get] .isMobileData" - case .p_internetReachableSubject_get: return "[get] .internetReachableSubject" + case .m_createCalendarIfNeeded: return ".createCalendarIfNeeded()" + case .m_filterCoursesBySelected__fetchedCourses_fetchedCourses: return ".filterCoursesBySelected(fetchedCourses:)" + case .m_removeOldCalendar: return ".removeOldCalendar()" + case .m_removeOutdatedEvents__courseID_courseID: return ".removeOutdatedEvents(courseID:)" + case .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates: return ".syncCourse(courseID:courseName:dates:)" + case .m_requestAccess: return ".requestAccess()" + case .m_courseStatus__courseID_courseID: return ".courseStatus(courseID:)" + case .m_clearAllData__removeCalendar_removeCalendar: return ".clearAllData(removeCalendar:)" + case .m_isDatesChanged__courseID_courseIDchecksum_checksum: return ".isDatesChanged(courseID:checksum:)" } } } @@ -1075,30 +1200,94 @@ open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { super.init(products) } - public static func isInternetAvaliable(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_isInternetAvaliable_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>, willReturn: [CourseForSync]...) -> MethodStub { + return Given(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`), products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func isMobileData(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_isMobileData_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + public static func requestAccess(willReturn: Bool...) -> MethodStub { + return Given(method: .m_requestAccess, products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func internetReachableSubject(getter defaultValue: CurrentValueSubject...) -> PropertyStub { - return Given(method: .p_internetReachableSubject_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + public static func courseStatus(courseID: Parameter, willReturn: SyncStatus...) -> MethodStub { + return Given(method: .m_courseStatus__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func isDatesChanged(courseID: Parameter, checksum: Parameter, willReturn: Bool...) -> MethodStub { + return Given(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>, willProduce: (Stubber<[CourseForSync]>) -> Void) -> MethodStub { + let willReturn: [[CourseForSync]] = [] + let given: Given = { return Given(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseForSync]).self) + willProduce(stubber) + return given + } + public static func requestAccess(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Bool] = [] + let given: Given = { return Given(method: .m_requestAccess, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Bool).self) + willProduce(stubber) + return given + } + public static func courseStatus(courseID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [SyncStatus] = [] + let given: Given = { return Given(method: .m_courseStatus__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (SyncStatus).self) + willProduce(stubber) + return given + } + public static func isDatesChanged(courseID: Parameter, checksum: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Bool] = [] + let given: Given = { return Given(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Bool).self) + willProduce(stubber) + return given } - } public struct Verify { fileprivate var method: MethodType - public static var isInternetAvaliable: Verify { return Verify(method: .p_isInternetAvaliable_get) } - public static var isMobileData: Verify { return Verify(method: .p_isMobileData_get) } - public static var internetReachableSubject: Verify { return Verify(method: .p_internetReachableSubject_get) } + public static func createCalendarIfNeeded() -> Verify { return Verify(method: .m_createCalendarIfNeeded)} + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>) -> Verify { return Verify(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`))} + public static func removeOldCalendar() -> Verify { return Verify(method: .m_removeOldCalendar)} + public static func removeOutdatedEvents(courseID: Parameter) -> Verify { return Verify(method: .m_removeOutdatedEvents__courseID_courseID(`courseID`))} + public static func syncCourse(courseID: Parameter, courseName: Parameter, dates: Parameter) -> Verify { return Verify(method: .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(`courseID`, `courseName`, `dates`))} + public static func requestAccess() -> Verify { return Verify(method: .m_requestAccess)} + public static func courseStatus(courseID: Parameter) -> Verify { return Verify(method: .m_courseStatus__courseID_courseID(`courseID`))} + public static func clearAllData(removeCalendar: Parameter) -> Verify { return Verify(method: .m_clearAllData__removeCalendar_removeCalendar(`removeCalendar`))} + public static func isDatesChanged(courseID: Parameter, checksum: Parameter) -> Verify { return Verify(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`))} } public struct Perform { fileprivate var method: MethodType var performs: Any + public static func createCalendarIfNeeded(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_createCalendarIfNeeded, performs: perform) + } + public static func filterCoursesBySelected(fetchedCourses: Parameter<[CourseForSync]>, perform: @escaping ([CourseForSync]) -> Void) -> Perform { + return Perform(method: .m_filterCoursesBySelected__fetchedCourses_fetchedCourses(`fetchedCourses`), performs: perform) + } + public static func removeOldCalendar(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_removeOldCalendar, performs: perform) + } + public static func removeOutdatedEvents(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_removeOutdatedEvents__courseID_courseID(`courseID`), performs: perform) + } + public static func syncCourse(courseID: Parameter, courseName: Parameter, dates: Parameter, perform: @escaping (String, String, CourseDates) -> Void) -> Perform { + return Perform(method: .m_syncCourse__courseID_courseIDcourseName_courseNamedates_dates(`courseID`, `courseName`, `dates`), performs: perform) + } + public static func requestAccess(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_requestAccess, performs: perform) + } + public static func courseStatus(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_courseStatus__courseID_courseID(`courseID`), performs: perform) + } + public static func clearAllData(removeCalendar: Parameter, perform: @escaping (Bool) -> Void) -> Perform { + return Perform(method: .m_clearAllData__removeCalendar_removeCalendar(`removeCalendar`), performs: perform) + } + public static func isDatesChanged(courseID: Parameter, checksum: Parameter, perform: @escaping (String, String) -> Void) -> Perform { + return Perform(method: .m_isDatesChanged__courseID_courseIDchecksum_checksum(`courseID`, `checksum`), performs: perform) + } } public func given(_ method: Given) { @@ -1174,9 +1363,9 @@ open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { } } -// MARK: - CoreAnalytics +// MARK: - ConfigProtocol -open class CoreAnalyticsMock: CoreAnalytics, Mock { +open class ConfigProtocolMock: ConfigProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() self.sequencingPolicy = sequencingPolicy @@ -1214,178 +1403,267 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { if scopes.contains(.perform) { methodPerformValues = [] } } + public var baseURL: URL { + get { invocations.append(.p_baseURL_get); return __p_baseURL ?? givenGetterValue(.p_baseURL_get, "ConfigProtocolMock - stub value for baseURL was not defined") } + } + private var __p_baseURL: (URL)? + public var baseSSOURL: URL { + get { invocations.append(.p_baseSSOURL_get); return __p_baseSSOURL ?? givenGetterValue(.p_baseSSOURL_get, "ConfigProtocolMock - stub value for baseSSOURL was not defined") } + } + private var __p_baseSSOURL: (URL)? + public var ssoFinishedURL: URL { + get { invocations.append(.p_ssoFinishedURL_get); return __p_ssoFinishedURL ?? givenGetterValue(.p_ssoFinishedURL_get, "ConfigProtocolMock - stub value for ssoFinishedURL was not defined") } + } + private var __p_ssoFinishedURL: (URL)? + public var ssoButtonTitle: [String: Any] { + get { invocations.append(.p_ssoButtonTitle_get); return __p_ssoButtonTitle ?? givenGetterValue(.p_ssoButtonTitle_get, "ConfigProtocolMock - stub value for ssoButtonTitle was not defined") } + } + private var __p_ssoButtonTitle: ([String: Any])? - 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`) - } + public var oAuthClientId: String { + get { invocations.append(.p_oAuthClientId_get); return __p_oAuthClientId ?? givenGetterValue(.p_oAuthClientId_get, "ConfigProtocolMock - stub value for oAuthClientId was not defined") } + } + private var __p_oAuthClientId: (String)? - 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`) - } + public var tokenType: TokenType { + get { invocations.append(.p_tokenType_get); return __p_tokenType ?? givenGetterValue(.p_tokenType_get, "ConfigProtocolMock - stub value for tokenType was not defined") } + } + private var __p_tokenType: (TokenType)? - open func trackScreenEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { - addInvocation(.m_trackScreenEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) - let perform = methodPerformValue(.m_trackScreenEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, [String: Any]?) -> Void - perform?(`event`, `parameters`) - } + public var feedbackEmail: String { + get { invocations.append(.p_feedbackEmail_get); return __p_feedbackEmail ?? givenGetterValue(.p_feedbackEmail_get, "ConfigProtocolMock - stub value for feedbackEmail was not defined") } + } + private var __p_feedbackEmail: (String)? - open func trackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { - addInvocation(.m_trackScreenEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) - let perform = methodPerformValue(.m_trackScreenEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void - perform?(`event`, `biValue`, `parameters`) - } + public var appStoreLink: String { + get { invocations.append(.p_appStoreLink_get); return __p_appStoreLink ?? givenGetterValue(.p_appStoreLink_get, "ConfigProtocolMock - stub value for appStoreLink was not defined") } + } + private var __p_appStoreLink: (String)? - 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`) - } + public var faq: URL? { + get { invocations.append(.p_faq_get); return __p_faq ?? optionalGivenGetterValue(.p_faq_get, "ConfigProtocolMock - stub value for faq was not defined") } + } + private var __p_faq: (URL)? - 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`) - } + public var platformName: String { + get { invocations.append(.p_platformName_get); return __p_platformName ?? givenGetterValue(.p_platformName_get, "ConfigProtocolMock - stub value for platformName was not defined") } + } + private var __p_platformName: (String)? - 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`) - } + public var agreement: AgreementConfig { + get { invocations.append(.p_agreement_get); return __p_agreement ?? givenGetterValue(.p_agreement_get, "ConfigProtocolMock - stub value for agreement was not defined") } + } + private var __p_agreement: (AgreementConfig)? - 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`) - } + public var firebase: FirebaseConfig { + get { invocations.append(.p_firebase_get); return __p_firebase ?? givenGetterValue(.p_firebase_get, "ConfigProtocolMock - stub value for firebase was not defined") } + } + private var __p_firebase: (FirebaseConfig)? - open func trackScreenEvent(_ event: AnalyticsEvent) { - addInvocation(.m_trackScreenEvent__event(Parameter.value(`event`))) - let perform = methodPerformValue(.m_trackScreenEvent__event(Parameter.value(`event`))) as? (AnalyticsEvent) -> Void - perform?(`event`) - } + public var facebook: FacebookConfig { + get { invocations.append(.p_facebook_get); return __p_facebook ?? givenGetterValue(.p_facebook_get, "ConfigProtocolMock - stub value for facebook was not defined") } + } + private var __p_facebook: (FacebookConfig)? - open func trackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { - addInvocation(.m_trackScreenEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) - let perform = methodPerformValue(.m_trackScreenEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void - perform?(`event`, `biValue`) - } + public var microsoft: MicrosoftConfig { + get { invocations.append(.p_microsoft_get); return __p_microsoft ?? givenGetterValue(.p_microsoft_get, "ConfigProtocolMock - stub value for microsoft was not defined") } + } + private var __p_microsoft: (MicrosoftConfig)? + public var google: GoogleConfig { + get { invocations.append(.p_google_get); return __p_google ?? givenGetterValue(.p_google_get, "ConfigProtocolMock - stub value for google was not defined") } + } + private var __p_google: (GoogleConfig)? - 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_trackScreenEvent__eventparameters_parameters(Parameter, Parameter<[String: Any]?>) - case m_trackScreenEvent__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) - case m_trackScreenEvent__event(Parameter) - case m_trackScreenEvent__eventbiValue_biValue(Parameter, Parameter) + public var appleSignIn: AppleSignInConfig { + get { invocations.append(.p_appleSignIn_get); return __p_appleSignIn ?? givenGetterValue(.p_appleSignIn_get, "ConfigProtocolMock - stub value for appleSignIn was not defined") } + } + private var __p_appleSignIn: (AppleSignInConfig)? - 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) + public var features: FeaturesConfig { + get { invocations.append(.p_features_get); return __p_features ?? givenGetterValue(.p_features_get, "ConfigProtocolMock - stub value for features was not defined") } + } + private var __p_features: (FeaturesConfig)? - 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) + public var theme: ThemeConfig { + get { invocations.append(.p_theme_get); return __p_theme ?? givenGetterValue(.p_theme_get, "ConfigProtocolMock - stub value for theme was not defined") } + } + private var __p_theme: (ThemeConfig)? - case (.m_trackScreenEvent__eventparameters_parameters(let lhsEvent, let lhsParameters), .m_trackScreenEvent__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) + public var uiComponents: UIComponentsConfig { + get { invocations.append(.p_uiComponents_get); return __p_uiComponents ?? givenGetterValue(.p_uiComponents_get, "ConfigProtocolMock - stub value for uiComponents was not defined") } + } + private var __p_uiComponents: (UIComponentsConfig)? - case (.m_trackScreenEvent__eventbiValue_biValueparameters_parameters(let lhsEvent, let lhsBivalue, let lhsParameters), .m_trackScreenEvent__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) + public var discovery: DiscoveryConfig { + get { invocations.append(.p_discovery_get); return __p_discovery ?? givenGetterValue(.p_discovery_get, "ConfigProtocolMock - stub value for discovery was not defined") } + } + private var __p_discovery: (DiscoveryConfig)? - 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) + public var dashboard: DashboardConfig { + get { invocations.append(.p_dashboard_get); return __p_dashboard ?? givenGetterValue(.p_dashboard_get, "ConfigProtocolMock - stub value for dashboard was not defined") } + } + private var __p_dashboard: (DashboardConfig)? - 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) + public var braze: BrazeConfig { + get { invocations.append(.p_braze_get); return __p_braze ?? givenGetterValue(.p_braze_get, "ConfigProtocolMock - stub value for braze was not defined") } + } + private var __p_braze: (BrazeConfig)? - 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) + public var branch: BranchConfig { + get { invocations.append(.p_branch_get); return __p_branch ?? givenGetterValue(.p_branch_get, "ConfigProtocolMock - stub value for branch was not defined") } + } + private var __p_branch: (BranchConfig)? - 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) + public var segment: SegmentConfig { + get { invocations.append(.p_segment_get); return __p_segment ?? givenGetterValue(.p_segment_get, "ConfigProtocolMock - stub value for segment was not defined") } + } + private var __p_segment: (SegmentConfig)? - case (.m_trackScreenEvent__event(let lhsEvent), .m_trackScreenEvent__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) + public var program: DiscoveryConfig { + get { invocations.append(.p_program_get); return __p_program ?? givenGetterValue(.p_program_get, "ConfigProtocolMock - stub value for program was not defined") } + } + private var __p_program: (DiscoveryConfig)? - case (.m_trackScreenEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_trackScreenEvent__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) + public var URIScheme: String { + get { invocations.append(.p_URIScheme_get); return __p_URIScheme ?? givenGetterValue(.p_URIScheme_get, "ConfigProtocolMock - stub value for URIScheme was not defined") } + } + private var __p_URIScheme: (String)? + + public var fullStory: FullStoryConfig { + get { invocations.append(.p_fullStory_get); return __p_fullStory ?? givenGetterValue(.p_fullStory_get, "ConfigProtocolMock - stub value for fullStory was not defined") } + } + private var __p_fullStory: (FullStoryConfig)? + + + + + + + fileprivate enum MethodType { + case p_baseURL_get + case p_baseSSOURL_get + case p_ssoFinishedURL_get + case p_ssoButtonTitle_get + case p_oAuthClientId_get + case p_tokenType_get + case p_feedbackEmail_get + case p_appStoreLink_get + case p_faq_get + case p_platformName_get + case p_agreement_get + case p_firebase_get + case p_facebook_get + case p_microsoft_get + case p_google_get + case p_appleSignIn_get + case p_features_get + case p_theme_get + case p_uiComponents_get + case p_discovery_get + case p_dashboard_get + case p_braze_get + case p_branch_get + case p_segment_get + case p_program_get + case p_URIScheme_get + case p_fullStory_get + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { case (.p_baseURL_get,.p_baseURL_get): return Matcher.ComparisonResult.match + case (.p_baseSSOURL_get,.p_baseSSOURL_get): return Matcher.ComparisonResult.match + case (.p_ssoFinishedURL_get,.p_ssoFinishedURL_get): return Matcher.ComparisonResult.match + case (.p_ssoButtonTitle_get,.p_ssoButtonTitle_get): return Matcher.ComparisonResult.match + case (.p_oAuthClientId_get,.p_oAuthClientId_get): return Matcher.ComparisonResult.match + case (.p_tokenType_get,.p_tokenType_get): return Matcher.ComparisonResult.match + case (.p_feedbackEmail_get,.p_feedbackEmail_get): return Matcher.ComparisonResult.match + case (.p_appStoreLink_get,.p_appStoreLink_get): return Matcher.ComparisonResult.match + case (.p_faq_get,.p_faq_get): return Matcher.ComparisonResult.match + case (.p_platformName_get,.p_platformName_get): return Matcher.ComparisonResult.match + case (.p_agreement_get,.p_agreement_get): return Matcher.ComparisonResult.match + case (.p_firebase_get,.p_firebase_get): return Matcher.ComparisonResult.match + case (.p_facebook_get,.p_facebook_get): return Matcher.ComparisonResult.match + case (.p_microsoft_get,.p_microsoft_get): return Matcher.ComparisonResult.match + case (.p_google_get,.p_google_get): return Matcher.ComparisonResult.match + case (.p_appleSignIn_get,.p_appleSignIn_get): return Matcher.ComparisonResult.match + case (.p_features_get,.p_features_get): return Matcher.ComparisonResult.match + case (.p_theme_get,.p_theme_get): return Matcher.ComparisonResult.match + case (.p_uiComponents_get,.p_uiComponents_get): return Matcher.ComparisonResult.match + case (.p_discovery_get,.p_discovery_get): return Matcher.ComparisonResult.match + case (.p_dashboard_get,.p_dashboard_get): return Matcher.ComparisonResult.match + case (.p_braze_get,.p_braze_get): return Matcher.ComparisonResult.match + case (.p_branch_get,.p_branch_get): return Matcher.ComparisonResult.match + case (.p_segment_get,.p_segment_get): return Matcher.ComparisonResult.match + case (.p_program_get,.p_program_get): return Matcher.ComparisonResult.match + case (.p_URIScheme_get,.p_URIScheme_get): return Matcher.ComparisonResult.match + case (.p_fullStory_get,.p_fullStory_get): return Matcher.ComparisonResult.match 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_trackScreenEvent__eventparameters_parameters(p0, p1): return p0.intValue + p1.intValue - case let .m_trackScreenEvent__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 - case let .m_trackScreenEvent__event(p0): return p0.intValue - case let .m_trackScreenEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue + case .p_baseURL_get: return 0 + case .p_baseSSOURL_get: return 0 + case .p_ssoFinishedURL_get: return 0 + case .p_ssoButtonTitle_get: return 0 + case .p_oAuthClientId_get: return 0 + case .p_tokenType_get: return 0 + case .p_feedbackEmail_get: return 0 + case .p_appStoreLink_get: return 0 + case .p_faq_get: return 0 + case .p_platformName_get: return 0 + case .p_agreement_get: return 0 + case .p_firebase_get: return 0 + case .p_facebook_get: return 0 + case .p_microsoft_get: return 0 + case .p_google_get: return 0 + case .p_appleSignIn_get: return 0 + case .p_features_get: return 0 + case .p_theme_get: return 0 + case .p_uiComponents_get: return 0 + case .p_discovery_get: return 0 + case .p_dashboard_get: return 0 + case .p_braze_get: return 0 + case .p_branch_get: return 0 + case .p_segment_get: return 0 + case .p_program_get: return 0 + case .p_URIScheme_get: return 0 + case .p_fullStory_get: return 0 } } 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_trackScreenEvent__eventparameters_parameters: return ".trackScreenEvent(_:parameters:)" - case .m_trackScreenEvent__eventbiValue_biValueparameters_parameters: return ".trackScreenEvent(_: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:)" - case .m_trackScreenEvent__event: return ".trackScreenEvent(_:)" - case .m_trackScreenEvent__eventbiValue_biValue: return ".trackScreenEvent(_:biValue:)" + case .p_baseURL_get: return "[get] .baseURL" + case .p_baseSSOURL_get: return "[get] .baseSSOURL" + case .p_ssoFinishedURL_get: return "[get] .ssoFinishedURL" + case .p_ssoButtonTitle_get: return "[get] .ssoButtonTitle" + case .p_oAuthClientId_get: return "[get] .oAuthClientId" + case .p_tokenType_get: return "[get] .tokenType" + case .p_feedbackEmail_get: return "[get] .feedbackEmail" + case .p_appStoreLink_get: return "[get] .appStoreLink" + case .p_faq_get: return "[get] .faq" + case .p_platformName_get: return "[get] .platformName" + case .p_agreement_get: return "[get] .agreement" + case .p_firebase_get: return "[get] .firebase" + case .p_facebook_get: return "[get] .facebook" + case .p_microsoft_get: return "[get] .microsoft" + case .p_google_get: return "[get] .google" + case .p_appleSignIn_get: return "[get] .appleSignIn" + case .p_features_get: return "[get] .features" + case .p_theme_get: return "[get] .theme" + case .p_uiComponents_get: return "[get] .uiComponents" + case .p_discovery_get: return "[get] .discovery" + case .p_dashboard_get: return "[get] .dashboard" + case .p_braze_get: return "[get] .braze" + case .p_branch_get: return "[get] .branch" + case .p_segment_get: return "[get] .segment" + case .p_program_get: return "[get] .program" + case .p_URIScheme_get: return "[get] .URIScheme" + case .p_fullStory_get: return "[get] .fullStory" } } } @@ -1398,58 +1676,126 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { super.init(products) } + public static func baseURL(getter defaultValue: URL...) -> PropertyStub { + return Given(method: .p_baseURL_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func baseSSOURL(getter defaultValue: URL...) -> PropertyStub { + return Given(method: .p_baseSSOURL_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func ssoFinishedURL(getter defaultValue: URL...) -> PropertyStub { + return Given(method: .p_ssoFinishedURL_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func ssoButtonTitle(getter defaultValue: [String: Any]...) -> PropertyStub { + return Given(method: .p_ssoButtonTitle_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func oAuthClientId(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_oAuthClientId_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func tokenType(getter defaultValue: TokenType...) -> PropertyStub { + return Given(method: .p_tokenType_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func feedbackEmail(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_feedbackEmail_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appStoreLink(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_appStoreLink_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func faq(getter defaultValue: URL?...) -> PropertyStub { + return Given(method: .p_faq_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func platformName(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_platformName_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func agreement(getter defaultValue: AgreementConfig...) -> PropertyStub { + return Given(method: .p_agreement_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func firebase(getter defaultValue: FirebaseConfig...) -> PropertyStub { + return Given(method: .p_firebase_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func facebook(getter defaultValue: FacebookConfig...) -> PropertyStub { + return Given(method: .p_facebook_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func microsoft(getter defaultValue: MicrosoftConfig...) -> PropertyStub { + return Given(method: .p_microsoft_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func google(getter defaultValue: GoogleConfig...) -> PropertyStub { + return Given(method: .p_google_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appleSignIn(getter defaultValue: AppleSignInConfig...) -> PropertyStub { + return Given(method: .p_appleSignIn_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func features(getter defaultValue: FeaturesConfig...) -> PropertyStub { + return Given(method: .p_features_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func theme(getter defaultValue: ThemeConfig...) -> PropertyStub { + return Given(method: .p_theme_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func uiComponents(getter defaultValue: UIComponentsConfig...) -> PropertyStub { + return Given(method: .p_uiComponents_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func discovery(getter defaultValue: DiscoveryConfig...) -> PropertyStub { + return Given(method: .p_discovery_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func dashboard(getter defaultValue: DashboardConfig...) -> PropertyStub { + return Given(method: .p_dashboard_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func braze(getter defaultValue: BrazeConfig...) -> PropertyStub { + return Given(method: .p_braze_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func branch(getter defaultValue: BranchConfig...) -> PropertyStub { + return Given(method: .p_branch_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func segment(getter defaultValue: SegmentConfig...) -> PropertyStub { + return Given(method: .p_segment_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func program(getter defaultValue: DiscoveryConfig...) -> PropertyStub { + return Given(method: .p_program_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func URIScheme(getter defaultValue: String...) -> PropertyStub { + return Given(method: .p_URIScheme_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func fullStory(getter defaultValue: FullStoryConfig...) -> PropertyStub { + return Given(method: .p_fullStory_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } } 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 trackScreenEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackScreenEvent__eventparameters_parameters(`event`, `parameters`))} - public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackScreenEvent__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 static func trackScreenEvent(_ event: Parameter) -> Verify { return Verify(method: .m_trackScreenEvent__event(`event`))} - public static func trackScreenEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_trackScreenEvent__eventbiValue_biValue(`event`, `biValue`))} + public static var baseURL: Verify { return Verify(method: .p_baseURL_get) } + public static var baseSSOURL: Verify { return Verify(method: .p_baseSSOURL_get) } + public static var ssoFinishedURL: Verify { return Verify(method: .p_ssoFinishedURL_get) } + public static var ssoButtonTitle: Verify { return Verify(method: .p_ssoButtonTitle_get) } + public static var oAuthClientId: Verify { return Verify(method: .p_oAuthClientId_get) } + public static var tokenType: Verify { return Verify(method: .p_tokenType_get) } + public static var feedbackEmail: Verify { return Verify(method: .p_feedbackEmail_get) } + public static var appStoreLink: Verify { return Verify(method: .p_appStoreLink_get) } + public static var faq: Verify { return Verify(method: .p_faq_get) } + public static var platformName: Verify { return Verify(method: .p_platformName_get) } + public static var agreement: Verify { return Verify(method: .p_agreement_get) } + public static var firebase: Verify { return Verify(method: .p_firebase_get) } + public static var facebook: Verify { return Verify(method: .p_facebook_get) } + public static var microsoft: Verify { return Verify(method: .p_microsoft_get) } + public static var google: Verify { return Verify(method: .p_google_get) } + public static var appleSignIn: Verify { return Verify(method: .p_appleSignIn_get) } + public static var features: Verify { return Verify(method: .p_features_get) } + public static var theme: Verify { return Verify(method: .p_theme_get) } + public static var uiComponents: Verify { return Verify(method: .p_uiComponents_get) } + public static var discovery: Verify { return Verify(method: .p_discovery_get) } + public static var dashboard: Verify { return Verify(method: .p_dashboard_get) } + public static var braze: Verify { return Verify(method: .p_braze_get) } + public static var branch: Verify { return Verify(method: .p_branch_get) } + public static var segment: Verify { return Verify(method: .p_segment_get) } + public static var program: Verify { return Verify(method: .p_program_get) } + public static var URIScheme: Verify { return Verify(method: .p_URIScheme_get) } + public static var fullStory: Verify { return Verify(method: .p_fullStory_get) } } 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 trackScreenEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, [String: Any]?) -> Void) -> Perform { - return Perform(method: .m_trackScreenEvent__eventparameters_parameters(`event`, `parameters`), performs: perform) - } - public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void) -> Perform { - return Perform(method: .m_trackScreenEvent__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 static func trackScreenEvent(_ event: Parameter, perform: @escaping (AnalyticsEvent) -> Void) -> Perform { - return Perform(method: .m_trackScreenEvent__event(`event`), performs: perform) - } - public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { - return Perform(method: .m_trackScreenEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) - } } public func given(_ method: Given) { @@ -1525,9 +1871,9 @@ open class CoreAnalyticsMock: CoreAnalytics, Mock { } } -// MARK: - CorePersistenceProtocol +// MARK: - ConnectivityProtocol -open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { +open class ConnectivityProtocolMock: ConnectivityProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() self.sequencingPolicy = sequencingPolicy @@ -1565,269 +1911,343 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { if scopes.contains(.perform) { methodPerformValues = [] } } + public var isInternetAvaliable: Bool { + get { invocations.append(.p_isInternetAvaliable_get); return __p_isInternetAvaliable ?? givenGetterValue(.p_isInternetAvaliable_get, "ConnectivityProtocolMock - stub value for isInternetAvaliable was not defined") } + } + private var __p_isInternetAvaliable: (Bool)? + + public var isMobileData: Bool { + get { invocations.append(.p_isMobileData_get); return __p_isMobileData ?? givenGetterValue(.p_isMobileData_get, "ConnectivityProtocolMock - stub value for isMobileData was not defined") } + } + private var __p_isMobileData: (Bool)? + + public var internetReachableSubject: CurrentValueSubject { + get { invocations.append(.p_internetReachableSubject_get); return __p_internetReachableSubject ?? givenGetterValue(.p_internetReachableSubject_get, "ConnectivityProtocolMock - stub value for internetReachableSubject was not defined") } + } + private var __p_internetReachableSubject: (CurrentValueSubject)? - open func set(userId: Int) { - addInvocation(.m_set__userId_userId(Parameter.value(`userId`))) - let perform = methodPerformValue(.m_set__userId_userId(Parameter.value(`userId`))) as? (Int) -> Void - perform?(`userId`) - } - open func getUserID() -> Int? { - addInvocation(.m_getUserID) - let perform = methodPerformValue(.m_getUserID) as? () -> Void - perform?() - var __value: Int? = nil - do { - __value = try methodReturnValue(.m_getUserID).casted() - } catch { - // do nothing - } - return __value - } - open func publisher() -> AnyPublisher { - addInvocation(.m_publisher) - let perform = methodPerformValue(.m_publisher) as? () -> Void - perform?() - var __value: AnyPublisher - do { - __value = try methodReturnValue(.m_publisher).casted() - } catch { - onFatalFailure("Stub return value not specified for publisher(). Use given") - Failure("Stub return value not specified for publisher(). Use given") - } - return __value + fileprivate enum MethodType { + case p_isInternetAvaliable_get + case p_isMobileData_get + case p_internetReachableSubject_get + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { case (.p_isInternetAvaliable_get,.p_isInternetAvaliable_get): return Matcher.ComparisonResult.match + case (.p_isMobileData_get,.p_isMobileData_get): return Matcher.ComparisonResult.match + case (.p_internetReachableSubject_get,.p_internetReachableSubject_get): return Matcher.ComparisonResult.match + default: return .none + } + } + + func intValue() -> Int { + switch self { + case .p_isInternetAvaliable_get: return 0 + case .p_isMobileData_get: return 0 + case .p_internetReachableSubject_get: return 0 + } + } + func assertionName() -> String { + switch self { + case .p_isInternetAvaliable_get: return "[get] .isInternetAvaliable" + case .p_isMobileData_get: return "[get] .isMobileData" + case .p_internetReachableSubject_get: return "[get] .internetReachableSubject" + } + } } - open func addToDownloadQueue(tasks: [DownloadDataTask]) { - addInvocation(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) - let perform = methodPerformValue(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) as? ([DownloadDataTask]) -> Void - perform?(`tasks`) + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func isInternetAvaliable(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_isInternetAvaliable_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func isMobileData(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_isMobileData_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func internetReachableSubject(getter defaultValue: CurrentValueSubject...) -> PropertyStub { + return Given(method: .p_internetReachableSubject_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + } - open func saveOfflineProgress(progress: OfflineProgress) { - addInvocation(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) - let perform = methodPerformValue(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) as? (OfflineProgress) -> Void - perform?(`progress`) + public struct Verify { + fileprivate var method: MethodType + + public static var isInternetAvaliable: Verify { return Verify(method: .p_isInternetAvaliable_get) } + public static var isMobileData: Verify { return Verify(method: .p_isMobileData_get) } + public static var internetReachableSubject: Verify { return Verify(method: .p_internetReachableSubject_get) } } - open func loadProgress(for blockID: String) -> OfflineProgress? { - addInvocation(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) - let perform = methodPerformValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void - perform?(`blockID`) - var __value: OfflineProgress? = nil - do { - __value = try methodReturnValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))).casted() - } catch { - // do nothing - } - return __value + public struct Perform { + fileprivate var method: MethodType + var performs: Any + } - open func loadAllOfflineProgress() -> [OfflineProgress] { - addInvocation(.m_loadAllOfflineProgress) - let perform = methodPerformValue(.m_loadAllOfflineProgress) as? () -> Void - perform?() - var __value: [OfflineProgress] - do { - __value = try methodReturnValue(.m_loadAllOfflineProgress).casted() - } catch { - onFatalFailure("Stub return value not specified for loadAllOfflineProgress(). Use given") - Failure("Stub return value not specified for loadAllOfflineProgress(). Use given") - } - return __value + public func given(_ method: Given) { + methodReturnValues.append(method) } - open func deleteProgress(for blockID: String) { - addInvocation(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) - let perform = methodPerformValue(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void - perform?(`blockID`) + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } } - open func deleteAllProgress() { - addInvocation(.m_deleteAllProgress) - let perform = methodPerformValue(.m_deleteAllProgress) as? () -> Void - perform?() + 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) } - open func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) { - addInvocation(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) - let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) as? ([CourseBlock], DownloadQuality) -> Void - perform?(`blocks`, `downloadQuality`) + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } } - - open func nextBlockForDownloading() -> DownloadDataTask? { - addInvocation(.m_nextBlockForDownloading) - let perform = methodPerformValue(.m_nextBlockForDownloading) as? () -> Void - perform?() - var __value: DownloadDataTask? = nil - do { - __value = try methodReturnValue(.m_nextBlockForDownloading).casted() - } catch { - // do nothing - } - return __value + 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 } - - open func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) { - addInvocation(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) - let perform = methodPerformValue(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) as? (String, DownloadState, Data?) -> Void - perform?(`id`, `state`, `resumeData`) + 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 } - - open func deleteDownloadDataTask(id: String) throws { - addInvocation(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) - let perform = methodPerformValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) as? (String) -> Void - perform?(`id`) - do { - _ = try methodReturnValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))).casted() as Void - } catch MockError.notStubed { - // do nothing - } catch { - throw error - } + 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 } } - - open func saveDownloadDataTask(_ task: DownloadDataTask) { - addInvocation(.m_saveDownloadDataTask__task(Parameter.value(`task`))) - let perform = methodPerformValue(.m_saveDownloadDataTask__task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void - perform?(`task`) + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count } - - open func downloadDataTask(for blockId: String) -> DownloadDataTask? { - addInvocation(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) - let perform = methodPerformValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void - perform?(`blockId`) - var __value: DownloadDataTask? = nil - do { - __value = try methodReturnValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))).casted() - } catch { - // do nothing - } - return __value + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } } - - open func getDownloadDataTasks() -> [DownloadDataTask] { - addInvocation(.m_getDownloadDataTasks) - let perform = methodPerformValue(.m_getDownloadDataTasks) as? () -> Void - perform?() - var __value: [DownloadDataTask] - do { - __value = try methodReturnValue(.m_getDownloadDataTasks).casted() - } catch { - onFatalFailure("Stub return value not specified for getDownloadDataTasks(). Use given") - Failure("Stub return value not specified for getDownloadDataTasks(). Use given") - } - return __value + private func optionalGivenGetterValue(_ method: MethodType, _ message: String) -> T? { + do { + return try methodReturnValue(method).casted() + } catch { + return nil + } } - - open func getDownloadDataTasksForCourse(_ courseId: String) -> [DownloadDataTask] { - addInvocation(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) - let perform = methodPerformValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void - perform?(`courseId`) - var __value: [DownloadDataTask] - do { - __value = try methodReturnValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))).casted() - } catch { - onFatalFailure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") - Failure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") - } - return __value + 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: - CoreAnalytics - fileprivate enum MethodType { - case m_set__userId_userId(Parameter) - case m_getUserID - case m_publisher - case m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>) - case m_saveOfflineProgress__progress_progress(Parameter) - case m_loadProgress__for_blockID(Parameter) - case m_loadAllOfflineProgress - case m_deleteProgress__for_blockID(Parameter) - case m_deleteAllProgress - case m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>, Parameter) - case m_nextBlockForDownloading - case m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter, Parameter, Parameter) - case m_deleteDownloadDataTask__id_id(Parameter) - case m_saveDownloadDataTask__task(Parameter) - case m_downloadDataTask__for_blockId(Parameter) - case m_getDownloadDataTasks - case m_getDownloadDataTasksForCourse__courseId(Parameter) +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 + } - static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { - switch (lhs, rhs) { - case (.m_set__userId_userId(let lhsUserid), .m_set__userId_userId(let rhsUserid)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUserid, rhs: rhsUserid, with: matcher), lhsUserid, rhsUserid, "userId")) - return Matcher.ComparisonResult(results) + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst - case (.m_getUserID, .m_getUserID): return .match + 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? - case (.m_publisher, .m_publisher): return .match + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given - case (.m_addToDownloadQueue__tasks_tasks(let lhsTasks), .m_addToDownloadQueue__tasks_tasks(let rhsTasks)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTasks, rhs: rhsTasks, with: matcher), lhsTasks, rhsTasks, "tasks")) - return Matcher.ComparisonResult(results) + /// 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 + } - case (.m_saveOfflineProgress__progress_progress(let lhsProgress), .m_saveOfflineProgress__progress_progress(let rhsProgress)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsProgress, rhs: rhsProgress, with: matcher), lhsProgress, rhsProgress, "progress")) - return Matcher.ComparisonResult(results) + /// 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 = [] } + } - case (.m_loadProgress__for_blockID(let lhsBlockid), .m_loadProgress__for_blockID(let rhsBlockid)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) - return Matcher.ComparisonResult(results) - case (.m_loadAllOfflineProgress, .m_loadAllOfflineProgress): return .match - case (.m_deleteProgress__for_blockID(let lhsBlockid), .m_deleteProgress__for_blockID(let rhsBlockid)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) - return Matcher.ComparisonResult(results) - case (.m_deleteAllProgress, .m_deleteAllProgress): return .match - case (.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let lhsBlocks, let lhsDownloadquality), .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let rhsBlocks, let rhsDownloadquality)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDownloadquality, rhs: rhsDownloadquality, with: matcher), lhsDownloadquality, rhsDownloadquality, "downloadQuality")) - return Matcher.ComparisonResult(results) + 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`) + } - case (.m_nextBlockForDownloading, .m_nextBlockForDownloading): return .match + 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`) + } - case (.m_updateDownloadState__id_idstate_stateresumeData_resumeData(let lhsId, let lhsState, let lhsResumedata), .m_updateDownloadState__id_idstate_stateresumeData_resumeData(let rhsId, let rhsState, let rhsResumedata)): - 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: lhsState, rhs: rhsState, with: matcher), lhsState, rhsState, "state")) - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsResumedata, rhs: rhsResumedata, with: matcher), lhsResumedata, rhsResumedata, "resumeData")) - return Matcher.ComparisonResult(results) + open func trackScreenEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { + addInvocation(.m_trackScreenEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackScreenEvent__eventparameters_parameters(Parameter.value(`event`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, [String: Any]?) -> Void + perform?(`event`, `parameters`) + } - case (.m_deleteDownloadDataTask__id_id(let lhsId), .m_deleteDownloadDataTask__id_id(let rhsId)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "id")) - return Matcher.ComparisonResult(results) + open func trackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue, parameters: [String: Any]?) { + addInvocation(.m_trackScreenEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) + let perform = methodPerformValue(.m_trackScreenEvent__eventbiValue_biValueparameters_parameters(Parameter.value(`event`), Parameter.value(`biValue`), Parameter<[String: Any]?>.value(`parameters`))) as? (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void + perform?(`event`, `biValue`, `parameters`) + } - case (.m_saveDownloadDataTask__task(let lhsTask), .m_saveDownloadDataTask__task(let rhsTask)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "_ task")) - return Matcher.ComparisonResult(results) + 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`) + } - case (.m_downloadDataTask__for_blockId(let lhsBlockid), .m_downloadDataTask__for_blockId(let rhsBlockid)): + 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`) + } + + open func trackScreenEvent(_ event: AnalyticsEvent) { + addInvocation(.m_trackScreenEvent__event(Parameter.value(`event`))) + let perform = methodPerformValue(.m_trackScreenEvent__event(Parameter.value(`event`))) as? (AnalyticsEvent) -> Void + perform?(`event`) + } + + open func trackScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_trackScreenEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_trackScreenEvent__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_trackScreenEvent__eventparameters_parameters(Parameter, Parameter<[String: Any]?>) + case m_trackScreenEvent__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) + case m_trackScreenEvent__event(Parameter) + case m_trackScreenEvent__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: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + 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_getDownloadDataTasks, .m_getDownloadDataTasks): return .match + 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_getDownloadDataTasksForCourse__courseId(let lhsCourseid), .m_getDownloadDataTasksForCourse__courseId(let rhsCourseid)): + case (.m_trackScreenEvent__eventparameters_parameters(let lhsEvent, let lhsParameters), .m_trackScreenEvent__eventparameters_parameters(let rhsEvent, let rhsParameters)): 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: 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_trackScreenEvent__eventbiValue_biValueparameters_parameters(let lhsEvent, let lhsBivalue, let lhsParameters), .m_trackScreenEvent__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) + + case (.m_trackScreenEvent__event(let lhsEvent), .m_trackScreenEvent__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_trackScreenEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_trackScreenEvent__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 } @@ -1835,44 +2255,30 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { func intValue() -> Int { switch self { - case let .m_set__userId_userId(p0): return p0.intValue - case .m_getUserID: return 0 - case .m_publisher: return 0 - case let .m_addToDownloadQueue__tasks_tasks(p0): return p0.intValue - case let .m_saveOfflineProgress__progress_progress(p0): return p0.intValue - case let .m_loadProgress__for_blockID(p0): return p0.intValue - case .m_loadAllOfflineProgress: return 0 - case let .m_deleteProgress__for_blockID(p0): return p0.intValue - case .m_deleteAllProgress: return 0 - case let .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(p0, p1): return p0.intValue + p1.intValue - case .m_nextBlockForDownloading: return 0 - case let .m_updateDownloadState__id_idstate_stateresumeData_resumeData(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue - case let .m_deleteDownloadDataTask__id_id(p0): return p0.intValue - case let .m_saveDownloadDataTask__task(p0): return p0.intValue - case let .m_downloadDataTask__for_blockId(p0): return p0.intValue - case .m_getDownloadDataTasks: return 0 - case let .m_getDownloadDataTasksForCourse__courseId(p0): return p0.intValue + 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_trackScreenEvent__eventparameters_parameters(p0, p1): return p0.intValue + p1.intValue + case let .m_trackScreenEvent__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 + case let .m_trackScreenEvent__event(p0): return p0.intValue + case let .m_trackScreenEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue } } func assertionName() -> String { switch self { - case .m_set__userId_userId: return ".set(userId:)" - case .m_getUserID: return ".getUserID()" - case .m_publisher: return ".publisher()" - case .m_addToDownloadQueue__tasks_tasks: return ".addToDownloadQueue(tasks:)" - case .m_saveOfflineProgress__progress_progress: return ".saveOfflineProgress(progress:)" - case .m_loadProgress__for_blockID: return ".loadProgress(for:)" - case .m_loadAllOfflineProgress: return ".loadAllOfflineProgress()" - case .m_deleteProgress__for_blockID: return ".deleteProgress(for:)" - case .m_deleteAllProgress: return ".deleteAllProgress()" - case .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality: return ".addToDownloadQueue(blocks:downloadQuality:)" - case .m_nextBlockForDownloading: return ".nextBlockForDownloading()" - case .m_updateDownloadState__id_idstate_stateresumeData_resumeData: return ".updateDownloadState(id:state:resumeData:)" - case .m_deleteDownloadDataTask__id_id: return ".deleteDownloadDataTask(id:)" - case .m_saveDownloadDataTask__task: return ".saveDownloadDataTask(_:)" - case .m_downloadDataTask__for_blockId: return ".downloadDataTask(for:)" - case .m_getDownloadDataTasks: return ".getDownloadDataTasks()" - case .m_getDownloadDataTasksForCourse__courseId: return ".getDownloadDataTasksForCourse(_:)" + case .m_trackEvent__eventparameters_parameters: return ".trackEvent(_:parameters:)" + case .m_trackEvent__eventbiValue_biValueparameters_parameters: return ".trackEvent(_:biValue:parameters:)" + case .m_trackScreenEvent__eventparameters_parameters: return ".trackScreenEvent(_:parameters:)" + case .m_trackScreenEvent__eventbiValue_biValueparameters_parameters: return ".trackScreenEvent(_: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:)" + case .m_trackScreenEvent__event: return ".trackScreenEvent(_:)" + case .m_trackScreenEvent__eventbiValue_biValue: return ".trackScreenEvent(_:biValue:)" } } } @@ -1886,174 +2292,56 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { } - public static func getUserID(willReturn: Int?...) -> MethodStub { - return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) - } - public static func publisher(willReturn: AnyPublisher...) -> MethodStub { - return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) - } - public static func loadProgress(for blockID: Parameter, willReturn: OfflineProgress?...) -> MethodStub { - return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) - } - public static func loadAllOfflineProgress(willReturn: [OfflineProgress]...) -> MethodStub { - return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + + 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 trackScreenEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackScreenEvent__eventparameters_parameters(`event`, `parameters`))} + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>) -> Verify { return Verify(method: .m_trackScreenEvent__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 static func trackScreenEvent(_ event: Parameter) -> Verify { return Verify(method: .m_trackScreenEvent__event(`event`))} + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_trackScreenEvent__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 nextBlockForDownloading(willReturn: DownloadDataTask?...) -> MethodStub { - return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) + 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 downloadDataTask(for blockId: Parameter, willReturn: DownloadDataTask?...) -> MethodStub { - return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + public static func trackScreenEvent(_ event: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__eventparameters_parameters(`event`, `parameters`), performs: perform) } - public static func getDownloadDataTasks(willReturn: [DownloadDataTask]...) -> MethodStub { - return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, parameters: Parameter<[String: Any]?>, perform: @escaping (AnalyticsEvent, EventBIValue, [String: Any]?) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__eventbiValue_biValueparameters_parameters(`event`, `biValue`, `parameters`), performs: perform) } - public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { - return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + 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 getUserID(willProduce: (Stubber) -> Void) -> MethodStub { - let willReturn: [Int?] = [] - let given: Given = { return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (Int?).self) - willProduce(stubber) - return given + 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 publisher(willProduce: (Stubber>) -> Void) -> MethodStub { - let willReturn: [AnyPublisher] = [] - let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (AnyPublisher).self) - willProduce(stubber) - return given + public static func trackEvent(_ event: Parameter, perform: @escaping (AnalyticsEvent) -> Void) -> Perform { + return Perform(method: .m_trackEvent__event(`event`), performs: perform) } - public static func loadProgress(for blockID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { - let willReturn: [OfflineProgress?] = [] - let given: Given = { return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (OfflineProgress?).self) - willProduce(stubber) - return given + 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 static func loadAllOfflineProgress(willProduce: (Stubber<[OfflineProgress]>) -> Void) -> MethodStub { - let willReturn: [[OfflineProgress]] = [] - let given: Given = { return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: ([OfflineProgress]).self) - willProduce(stubber) - return given + public static func trackScreenEvent(_ event: Parameter, perform: @escaping (AnalyticsEvent) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__event(`event`), performs: perform) } - public static func nextBlockForDownloading(willProduce: (Stubber) -> Void) -> MethodStub { - let willReturn: [DownloadDataTask?] = [] - let given: Given = { return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (DownloadDataTask?).self) - willProduce(stubber) - return given - } - public static func downloadDataTask(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { - let willReturn: [DownloadDataTask?] = [] - let given: Given = { return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (DownloadDataTask?).self) - willProduce(stubber) - return given - } - public static func getDownloadDataTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { - let willReturn: [[DownloadDataTask]] = [] - let given: Given = { return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: ([DownloadDataTask]).self) - willProduce(stubber) - return given - } - public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { - let willReturn: [[DownloadDataTask]] = [] - let given: Given = { return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: ([DownloadDataTask]).self) - willProduce(stubber) - return given - } - public static func deleteDownloadDataTask(id: Parameter, willThrow: Error...) -> MethodStub { - return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) - } - public static func deleteDownloadDataTask(id: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { - let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (Void).self) - willProduce(stubber) - return given - } - } - - public struct Verify { - fileprivate var method: MethodType - - public static func set(userId: Parameter) -> Verify { return Verify(method: .m_set__userId_userId(`userId`))} - public static func getUserID() -> Verify { return Verify(method: .m_getUserID)} - public static func publisher() -> Verify { return Verify(method: .m_publisher)} - public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>) -> Verify { return Verify(method: .m_addToDownloadQueue__tasks_tasks(`tasks`))} - public static func saveOfflineProgress(progress: Parameter) -> Verify { return Verify(method: .m_saveOfflineProgress__progress_progress(`progress`))} - public static func loadProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_loadProgress__for_blockID(`blockID`))} - public static func loadAllOfflineProgress() -> Verify { return Verify(method: .m_loadAllOfflineProgress)} - public static func deleteProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_deleteProgress__for_blockID(`blockID`))} - public static func deleteAllProgress() -> Verify { return Verify(method: .m_deleteAllProgress)} - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`))} - public static func nextBlockForDownloading() -> Verify { return Verify(method: .m_nextBlockForDownloading)} - public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter) -> Verify { return Verify(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`))} - public static func deleteDownloadDataTask(id: Parameter) -> Verify { return Verify(method: .m_deleteDownloadDataTask__id_id(`id`))} - public static func saveDownloadDataTask(_ task: Parameter) -> Verify { return Verify(method: .m_saveDownloadDataTask__task(`task`))} - public static func downloadDataTask(for blockId: Parameter) -> Verify { return Verify(method: .m_downloadDataTask__for_blockId(`blockId`))} - public static func getDownloadDataTasks() -> Verify { return Verify(method: .m_getDownloadDataTasks)} - public static func getDownloadDataTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`))} - } - - public struct Perform { - fileprivate var method: MethodType - var performs: Any - - public static func set(userId: Parameter, perform: @escaping (Int) -> Void) -> Perform { - return Perform(method: .m_set__userId_userId(`userId`), performs: perform) - } - public static func getUserID(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_getUserID, performs: perform) - } - public static func publisher(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_publisher, performs: perform) - } - public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>, perform: @escaping ([DownloadDataTask]) -> Void) -> Perform { - return Perform(method: .m_addToDownloadQueue__tasks_tasks(`tasks`), performs: perform) - } - public static func saveOfflineProgress(progress: Parameter, perform: @escaping (OfflineProgress) -> Void) -> Perform { - return Perform(method: .m_saveOfflineProgress__progress_progress(`progress`), performs: perform) - } - public static func loadProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { - return Perform(method: .m_loadProgress__for_blockID(`blockID`), performs: perform) - } - public static func loadAllOfflineProgress(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_loadAllOfflineProgress, performs: perform) - } - public static func deleteProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { - return Perform(method: .m_deleteProgress__for_blockID(`blockID`), performs: perform) - } - public static func deleteAllProgress(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_deleteAllProgress, performs: perform) - } - public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter, perform: @escaping ([CourseBlock], DownloadQuality) -> Void) -> Perform { - return Perform(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`), performs: perform) - } - public static func nextBlockForDownloading(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_nextBlockForDownloading, performs: perform) - } - public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter, perform: @escaping (String, DownloadState, Data?) -> Void) -> Perform { - return Perform(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`), performs: perform) - } - public static func deleteDownloadDataTask(id: Parameter, perform: @escaping (String) -> Void) -> Perform { - return Perform(method: .m_deleteDownloadDataTask__id_id(`id`), performs: perform) - } - public static func saveDownloadDataTask(_ task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { - return Perform(method: .m_saveDownloadDataTask__task(`task`), performs: perform) - } - public static func downloadDataTask(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { - return Perform(method: .m_downloadDataTask__for_blockId(`blockId`), performs: perform) - } - public static func getDownloadDataTasks(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_getDownloadDataTasks, performs: perform) - } - public static func getDownloadDataTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { - return Perform(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), performs: perform) + public static func trackScreenEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_trackScreenEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) } } @@ -2130,9 +2418,9 @@ open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { } } -// MARK: - CoreStorage +// MARK: - CorePersistenceProtocol -open class CoreStorageMock: CoreStorage, Mock { +open class CorePersistenceProtocolMock: CorePersistenceProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() self.sequencingPolicy = sequencingPolicy @@ -2170,877 +2458,1742 @@ open class CoreStorageMock: CoreStorage, Mock { if scopes.contains(.perform) { methodPerformValues = [] } } - public var accessToken: String? { - get { invocations.append(.p_accessToken_get); return __p_accessToken ?? optionalGivenGetterValue(.p_accessToken_get, "CoreStorageMock - stub value for accessToken was not defined") } - set { invocations.append(.p_accessToken_set(.value(newValue))); __p_accessToken = newValue } - } - private var __p_accessToken: (String)? - - public var refreshToken: String? { - get { invocations.append(.p_refreshToken_get); return __p_refreshToken ?? optionalGivenGetterValue(.p_refreshToken_get, "CoreStorageMock - stub value for refreshToken was not defined") } - set { invocations.append(.p_refreshToken_set(.value(newValue))); __p_refreshToken = newValue } - } - private var __p_refreshToken: (String)? - - public var pushToken: String? { - get { invocations.append(.p_pushToken_get); return __p_pushToken ?? optionalGivenGetterValue(.p_pushToken_get, "CoreStorageMock - stub value for pushToken was not defined") } - set { invocations.append(.p_pushToken_set(.value(newValue))); __p_pushToken = newValue } - } - private var __p_pushToken: (String)? - - public var appleSignFullName: String? { - get { invocations.append(.p_appleSignFullName_get); return __p_appleSignFullName ?? optionalGivenGetterValue(.p_appleSignFullName_get, "CoreStorageMock - stub value for appleSignFullName was not defined") } - set { invocations.append(.p_appleSignFullName_set(.value(newValue))); __p_appleSignFullName = newValue } - } - private var __p_appleSignFullName: (String)? - - public var appleSignEmail: String? { - get { invocations.append(.p_appleSignEmail_get); return __p_appleSignEmail ?? optionalGivenGetterValue(.p_appleSignEmail_get, "CoreStorageMock - stub value for appleSignEmail was not defined") } - set { invocations.append(.p_appleSignEmail_set(.value(newValue))); __p_appleSignEmail = newValue } - } - private var __p_appleSignEmail: (String)? - public var cookiesDate: Date? { - get { invocations.append(.p_cookiesDate_get); return __p_cookiesDate ?? optionalGivenGetterValue(.p_cookiesDate_get, "CoreStorageMock - stub value for cookiesDate was not defined") } - set { invocations.append(.p_cookiesDate_set(.value(newValue))); __p_cookiesDate = newValue } - } - private var __p_cookiesDate: (Date)? - public var reviewLastShownVersion: String? { - get { invocations.append(.p_reviewLastShownVersion_get); return __p_reviewLastShownVersion ?? optionalGivenGetterValue(.p_reviewLastShownVersion_get, "CoreStorageMock - stub value for reviewLastShownVersion was not defined") } - set { invocations.append(.p_reviewLastShownVersion_set(.value(newValue))); __p_reviewLastShownVersion = newValue } - } - private var __p_reviewLastShownVersion: (String)? - public var lastReviewDate: Date? { - get { invocations.append(.p_lastReviewDate_get); return __p_lastReviewDate ?? optionalGivenGetterValue(.p_lastReviewDate_get, "CoreStorageMock - stub value for lastReviewDate was not defined") } - set { invocations.append(.p_lastReviewDate_set(.value(newValue))); __p_lastReviewDate = newValue } - } - private var __p_lastReviewDate: (Date)? - public var user: DataLayer.User? { - get { invocations.append(.p_user_get); return __p_user ?? optionalGivenGetterValue(.p_user_get, "CoreStorageMock - stub value for user was not defined") } - set { invocations.append(.p_user_set(.value(newValue))); __p_user = newValue } - } - private var __p_user: (DataLayer.User)? + open func set(userId: Int) { + addInvocation(.m_set__userId_userId(Parameter.value(`userId`))) + let perform = methodPerformValue(.m_set__userId_userId(Parameter.value(`userId`))) as? (Int) -> Void + perform?(`userId`) + } - public var userSettings: UserSettings? { - get { invocations.append(.p_userSettings_get); return __p_userSettings ?? optionalGivenGetterValue(.p_userSettings_get, "CoreStorageMock - stub value for userSettings was not defined") } - set { invocations.append(.p_userSettings_set(.value(newValue))); __p_userSettings = newValue } - } - private var __p_userSettings: (UserSettings)? + open func getUserID() -> Int? { + addInvocation(.m_getUserID) + let perform = methodPerformValue(.m_getUserID) as? () -> Void + perform?() + var __value: Int? = nil + do { + __value = try methodReturnValue(.m_getUserID).casted() + } catch { + // do nothing + } + return __value + } - public var resetAppSupportDirectoryUserData: Bool? { - get { invocations.append(.p_resetAppSupportDirectoryUserData_get); return __p_resetAppSupportDirectoryUserData ?? optionalGivenGetterValue(.p_resetAppSupportDirectoryUserData_get, "CoreStorageMock - stub value for resetAppSupportDirectoryUserData was not defined") } - set { invocations.append(.p_resetAppSupportDirectoryUserData_set(.value(newValue))); __p_resetAppSupportDirectoryUserData = newValue } - } - private var __p_resetAppSupportDirectoryUserData: (Bool)? + open func publisher() -> AnyPublisher { + addInvocation(.m_publisher) + let perform = methodPerformValue(.m_publisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_publisher).casted() + } catch { + onFatalFailure("Stub return value not specified for publisher(). Use given") + Failure("Stub return value not specified for publisher(). Use given") + } + return __value + } - public var useRelativeDates: Bool { - get { invocations.append(.p_useRelativeDates_get); return __p_useRelativeDates ?? givenGetterValue(.p_useRelativeDates_get, "CoreStorageMock - stub value for useRelativeDates was not defined") } - set { invocations.append(.p_useRelativeDates_set(.value(newValue))); __p_useRelativeDates = newValue } - } - private var __p_useRelativeDates: (Bool)? + open func addToDownloadQueue(tasks: [DownloadDataTask]) { + addInvocation(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) + let perform = methodPerformValue(.m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>.value(`tasks`))) as? ([DownloadDataTask]) -> Void + perform?(`tasks`) + } + open func saveOfflineProgress(progress: OfflineProgress) { + addInvocation(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) + let perform = methodPerformValue(.m_saveOfflineProgress__progress_progress(Parameter.value(`progress`))) as? (OfflineProgress) -> Void + perform?(`progress`) + } + open func loadProgress(for blockID: String) -> OfflineProgress? { + addInvocation(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + var __value: OfflineProgress? = nil + do { + __value = try methodReturnValue(.m_loadProgress__for_blockID(Parameter.value(`blockID`))).casted() + } catch { + // do nothing + } + return __value + } + open func loadAllOfflineProgress() -> [OfflineProgress] { + addInvocation(.m_loadAllOfflineProgress) + let perform = methodPerformValue(.m_loadAllOfflineProgress) as? () -> Void + perform?() + var __value: [OfflineProgress] + do { + __value = try methodReturnValue(.m_loadAllOfflineProgress).casted() + } catch { + onFatalFailure("Stub return value not specified for loadAllOfflineProgress(). Use given") + Failure("Stub return value not specified for loadAllOfflineProgress(). Use given") + } + return __value + } + open func deleteProgress(for blockID: String) { + addInvocation(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) + let perform = methodPerformValue(.m_deleteProgress__for_blockID(Parameter.value(`blockID`))) as? (String) -> Void + perform?(`blockID`) + } - open func clear() { - addInvocation(.m_clear) - let perform = methodPerformValue(.m_clear) as? () -> Void + open func deleteAllProgress() { + addInvocation(.m_deleteAllProgress) + let perform = methodPerformValue(.m_deleteAllProgress) as? () -> Void perform?() } + open func addToDownloadQueue(blocks: [CourseBlock], downloadQuality: DownloadQuality) { + addInvocation(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) + let perform = methodPerformValue(.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>.value(`blocks`), Parameter.value(`downloadQuality`))) as? ([CourseBlock], DownloadQuality) -> Void + perform?(`blocks`, `downloadQuality`) + } - fileprivate enum MethodType { - case m_clear - case p_accessToken_get - case p_accessToken_set(Parameter) - case p_refreshToken_get - case p_refreshToken_set(Parameter) - case p_pushToken_get - case p_pushToken_set(Parameter) - case p_appleSignFullName_get - case p_appleSignFullName_set(Parameter) - case p_appleSignEmail_get - case p_appleSignEmail_set(Parameter) - case p_cookiesDate_get - case p_cookiesDate_set(Parameter) - case p_reviewLastShownVersion_get - case p_reviewLastShownVersion_set(Parameter) - case p_lastReviewDate_get - case p_lastReviewDate_set(Parameter) - case p_user_get - case p_user_set(Parameter) - case p_userSettings_get - case p_userSettings_set(Parameter) - case p_resetAppSupportDirectoryUserData_get - case p_resetAppSupportDirectoryUserData_set(Parameter) - case p_useRelativeDates_get - case p_useRelativeDates_set(Parameter) - - static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { - switch (lhs, rhs) { - case (.m_clear, .m_clear): return .match - case (.p_accessToken_get,.p_accessToken_get): return Matcher.ComparisonResult.match - case (.p_accessToken_set(let left),.p_accessToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_refreshToken_get,.p_refreshToken_get): return Matcher.ComparisonResult.match - case (.p_refreshToken_set(let left),.p_refreshToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_pushToken_get,.p_pushToken_get): return Matcher.ComparisonResult.match - case (.p_pushToken_set(let left),.p_pushToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_appleSignFullName_get,.p_appleSignFullName_get): return Matcher.ComparisonResult.match - case (.p_appleSignFullName_set(let left),.p_appleSignFullName_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_appleSignEmail_get,.p_appleSignEmail_get): return Matcher.ComparisonResult.match - case (.p_appleSignEmail_set(let left),.p_appleSignEmail_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_cookiesDate_get,.p_cookiesDate_get): return Matcher.ComparisonResult.match - case (.p_cookiesDate_set(let left),.p_cookiesDate_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_reviewLastShownVersion_get,.p_reviewLastShownVersion_get): return Matcher.ComparisonResult.match - case (.p_reviewLastShownVersion_set(let left),.p_reviewLastShownVersion_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_lastReviewDate_get,.p_lastReviewDate_get): return Matcher.ComparisonResult.match - case (.p_lastReviewDate_set(let left),.p_lastReviewDate_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_user_get,.p_user_get): return Matcher.ComparisonResult.match - case (.p_user_set(let left),.p_user_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_userSettings_get,.p_userSettings_get): return Matcher.ComparisonResult.match - case (.p_userSettings_set(let left),.p_userSettings_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_resetAppSupportDirectoryUserData_get,.p_resetAppSupportDirectoryUserData_get): return Matcher.ComparisonResult.match - case (.p_resetAppSupportDirectoryUserData_set(let left),.p_resetAppSupportDirectoryUserData_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) - case (.p_useRelativeDates_get,.p_useRelativeDates_get): return Matcher.ComparisonResult.match - case (.p_useRelativeDates_set(let left),.p_useRelativeDates_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 .m_clear: return 0 - case .p_accessToken_get: return 0 - case .p_accessToken_set(let newValue): return newValue.intValue - case .p_refreshToken_get: return 0 - case .p_refreshToken_set(let newValue): return newValue.intValue - case .p_pushToken_get: return 0 - case .p_pushToken_set(let newValue): return newValue.intValue - case .p_appleSignFullName_get: return 0 - case .p_appleSignFullName_set(let newValue): return newValue.intValue - case .p_appleSignEmail_get: return 0 - case .p_appleSignEmail_set(let newValue): return newValue.intValue - case .p_cookiesDate_get: return 0 - case .p_cookiesDate_set(let newValue): return newValue.intValue - case .p_reviewLastShownVersion_get: return 0 - case .p_reviewLastShownVersion_set(let newValue): return newValue.intValue - case .p_lastReviewDate_get: return 0 - case .p_lastReviewDate_set(let newValue): return newValue.intValue - case .p_user_get: return 0 - case .p_user_set(let newValue): return newValue.intValue - case .p_userSettings_get: return 0 - case .p_userSettings_set(let newValue): return newValue.intValue - case .p_resetAppSupportDirectoryUserData_get: return 0 - case .p_resetAppSupportDirectoryUserData_set(let newValue): return newValue.intValue - case .p_useRelativeDates_get: return 0 - case .p_useRelativeDates_set(let newValue): return newValue.intValue - } - } - func assertionName() -> String { - switch self { - case .m_clear: return ".clear()" - case .p_accessToken_get: return "[get] .accessToken" - case .p_accessToken_set: return "[set] .accessToken" - case .p_refreshToken_get: return "[get] .refreshToken" - case .p_refreshToken_set: return "[set] .refreshToken" - case .p_pushToken_get: return "[get] .pushToken" - case .p_pushToken_set: return "[set] .pushToken" - case .p_appleSignFullName_get: return "[get] .appleSignFullName" - case .p_appleSignFullName_set: return "[set] .appleSignFullName" - case .p_appleSignEmail_get: return "[get] .appleSignEmail" - case .p_appleSignEmail_set: return "[set] .appleSignEmail" - case .p_cookiesDate_get: return "[get] .cookiesDate" - case .p_cookiesDate_set: return "[set] .cookiesDate" - case .p_reviewLastShownVersion_get: return "[get] .reviewLastShownVersion" - case .p_reviewLastShownVersion_set: return "[set] .reviewLastShownVersion" - case .p_lastReviewDate_get: return "[get] .lastReviewDate" - case .p_lastReviewDate_set: return "[set] .lastReviewDate" - case .p_user_get: return "[get] .user" - case .p_user_set: return "[set] .user" - case .p_userSettings_get: return "[get] .userSettings" - case .p_userSettings_set: return "[set] .userSettings" - case .p_resetAppSupportDirectoryUserData_get: return "[get] .resetAppSupportDirectoryUserData" - case .p_resetAppSupportDirectoryUserData_set: return "[set] .resetAppSupportDirectoryUserData" - case .p_useRelativeDates_get: return "[get] .useRelativeDates" - case .p_useRelativeDates_set: return "[set] .useRelativeDates" - } - } + open func nextBlockForDownloading() -> DownloadDataTask? { + addInvocation(.m_nextBlockForDownloading) + let perform = methodPerformValue(.m_nextBlockForDownloading) as? () -> Void + perform?() + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_nextBlockForDownloading).casted() + } catch { + // do nothing + } + return __value } - open class Given: StubbedMethod { - fileprivate var method: MethodType - - private init(method: MethodType, products: [StubProduct]) { - self.method = method - super.init(products) - } - - public static func accessToken(getter defaultValue: String?...) -> PropertyStub { - return Given(method: .p_accessToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func refreshToken(getter defaultValue: String?...) -> PropertyStub { - return Given(method: .p_refreshToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func pushToken(getter defaultValue: String?...) -> PropertyStub { - return Given(method: .p_pushToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func appleSignFullName(getter defaultValue: String?...) -> PropertyStub { - return Given(method: .p_appleSignFullName_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func appleSignEmail(getter defaultValue: String?...) -> PropertyStub { - return Given(method: .p_appleSignEmail_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func cookiesDate(getter defaultValue: Date?...) -> PropertyStub { - return Given(method: .p_cookiesDate_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func reviewLastShownVersion(getter defaultValue: String?...) -> PropertyStub { - return Given(method: .p_reviewLastShownVersion_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func lastReviewDate(getter defaultValue: Date?...) -> PropertyStub { - return Given(method: .p_lastReviewDate_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func user(getter defaultValue: DataLayer.User?...) -> PropertyStub { - return Given(method: .p_user_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func userSettings(getter defaultValue: UserSettings?...) -> PropertyStub { - return Given(method: .p_userSettings_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func resetAppSupportDirectoryUserData(getter defaultValue: Bool?...) -> PropertyStub { - return Given(method: .p_resetAppSupportDirectoryUserData_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - public static func useRelativeDates(getter defaultValue: Bool...) -> PropertyStub { - return Given(method: .p_useRelativeDates_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - + open func updateDownloadState(id: String, state: DownloadState, resumeData: Data?) { + addInvocation(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) + let perform = methodPerformValue(.m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter.value(`id`), Parameter.value(`state`), Parameter.value(`resumeData`))) as? (String, DownloadState, Data?) -> Void + perform?(`id`, `state`, `resumeData`) } - public struct Verify { - fileprivate var method: MethodType - - public static func clear() -> Verify { return Verify(method: .m_clear)} - public static var accessToken: Verify { return Verify(method: .p_accessToken_get) } - public static func accessToken(set newValue: Parameter) -> Verify { return Verify(method: .p_accessToken_set(newValue)) } - public static var refreshToken: Verify { return Verify(method: .p_refreshToken_get) } - public static func refreshToken(set newValue: Parameter) -> Verify { return Verify(method: .p_refreshToken_set(newValue)) } - public static var pushToken: Verify { return Verify(method: .p_pushToken_get) } - public static func pushToken(set newValue: Parameter) -> Verify { return Verify(method: .p_pushToken_set(newValue)) } - public static var appleSignFullName: Verify { return Verify(method: .p_appleSignFullName_get) } - public static func appleSignFullName(set newValue: Parameter) -> Verify { return Verify(method: .p_appleSignFullName_set(newValue)) } - public static var appleSignEmail: Verify { return Verify(method: .p_appleSignEmail_get) } - public static func appleSignEmail(set newValue: Parameter) -> Verify { return Verify(method: .p_appleSignEmail_set(newValue)) } - public static var cookiesDate: Verify { return Verify(method: .p_cookiesDate_get) } - public static func cookiesDate(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesDate_set(newValue)) } - public static var reviewLastShownVersion: Verify { return Verify(method: .p_reviewLastShownVersion_get) } - public static func reviewLastShownVersion(set newValue: Parameter) -> Verify { return Verify(method: .p_reviewLastShownVersion_set(newValue)) } - public static var lastReviewDate: Verify { return Verify(method: .p_lastReviewDate_get) } - public static func lastReviewDate(set newValue: Parameter) -> Verify { return Verify(method: .p_lastReviewDate_set(newValue)) } - public static var user: Verify { return Verify(method: .p_user_get) } - public static func user(set newValue: Parameter) -> Verify { return Verify(method: .p_user_set(newValue)) } - public static var userSettings: Verify { return Verify(method: .p_userSettings_get) } - public static func userSettings(set newValue: Parameter) -> Verify { return Verify(method: .p_userSettings_set(newValue)) } - public static var resetAppSupportDirectoryUserData: Verify { return Verify(method: .p_resetAppSupportDirectoryUserData_get) } - public static func resetAppSupportDirectoryUserData(set newValue: Parameter) -> Verify { return Verify(method: .p_resetAppSupportDirectoryUserData_set(newValue)) } - public static var useRelativeDates: Verify { return Verify(method: .p_useRelativeDates_get) } - public static func useRelativeDates(set newValue: Parameter) -> Verify { return Verify(method: .p_useRelativeDates_set(newValue)) } + open func deleteDownloadDataTask(id: String) throws { + addInvocation(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) + let perform = methodPerformValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))) as? (String) -> Void + perform?(`id`) + do { + _ = try methodReturnValue(.m_deleteDownloadDataTask__id_id(Parameter.value(`id`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } } - public struct Perform { - fileprivate var method: MethodType - var performs: Any - - public static func clear(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_clear, performs: perform) - } + open func saveDownloadDataTask(_ task: DownloadDataTask) { + addInvocation(.m_saveDownloadDataTask__task(Parameter.value(`task`))) + let perform = methodPerformValue(.m_saveDownloadDataTask__task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void + perform?(`task`) } - public func given(_ method: Given) { - methodReturnValues.append(method) + open func downloadDataTask(for blockId: String) -> DownloadDataTask? { + addInvocation(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void + perform?(`blockId`) + var __value: DownloadDataTask? = nil + do { + __value = try methodReturnValue(.m_downloadDataTask__for_blockId(Parameter.value(`blockId`))).casted() + } catch { + // do nothing + } + return __value } - public func perform(_ method: Perform) { - methodPerformValues.append(method) - methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } + open func getDownloadDataTasks() -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasks) + let perform = methodPerformValue(.m_getDownloadDataTasks) as? () -> Void + perform?() + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasks).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasks(). Use given") + Failure("Stub return value not specified for getDownloadDataTasks(). Use given") + } + return __value } - 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) + open func getDownloadDataTasksForCourse(_ courseId: String) -> [DownloadDataTask] { + addInvocation(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadDataTasksForCourse__courseId(Parameter.value(`courseId`))).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadDataTasksForCourse(_ courseId: String). Use given") + } + return __value } - 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 + fileprivate enum MethodType { + case m_set__userId_userId(Parameter) + case m_getUserID + case m_publisher + case m_addToDownloadQueue__tasks_tasks(Parameter<[DownloadDataTask]>) + case m_saveOfflineProgress__progress_progress(Parameter) + case m_loadProgress__for_blockID(Parameter) + case m_loadAllOfflineProgress + case m_deleteProgress__for_blockID(Parameter) + case m_deleteAllProgress + case m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(Parameter<[CourseBlock]>, Parameter) + case m_nextBlockForDownloading + case m_updateDownloadState__id_idstate_stateresumeData_resumeData(Parameter, Parameter, Parameter) + case m_deleteDownloadDataTask__id_id(Parameter) + case m_saveDownloadDataTask__task(Parameter) + case m_downloadDataTask__for_blockId(Parameter) + case m_getDownloadDataTasks + case m_getDownloadDataTasksForCourse__courseId(Parameter) -open class DownloadManagerProtocolMock: DownloadManagerProtocol, 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 - } + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_set__userId_userId(let lhsUserid), .m_set__userId_userId(let rhsUserid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUserid, rhs: rhsUserid, with: matcher), lhsUserid, rhsUserid, "userId")) + return Matcher.ComparisonResult(results) - var matcher: Matcher = Matcher.default - var stubbingPolicy: StubbingPolicy = .wrap - var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + case (.m_getUserID, .m_getUserID): return .match - 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? + case (.m_publisher, .m_publisher): return .match - public typealias PropertyStub = Given - public typealias MethodStub = Given - public typealias SubscriptStub = Given + case (.m_addToDownloadQueue__tasks_tasks(let lhsTasks), .m_addToDownloadQueue__tasks_tasks(let rhsTasks)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTasks, rhs: rhsTasks, with: matcher), lhsTasks, rhsTasks, "tasks")) + return Matcher.ComparisonResult(results) - /// 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 - } + case (.m_saveOfflineProgress__progress_progress(let lhsProgress), .m_saveOfflineProgress__progress_progress(let rhsProgress)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsProgress, rhs: rhsProgress, with: matcher), lhsProgress, rhsProgress, "progress")) + return Matcher.ComparisonResult(results) - /// 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 = [] } - } + case (.m_loadProgress__for_blockID(let lhsBlockid), .m_loadProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) - public var currentDownloadTask: DownloadDataTask? { - get { invocations.append(.p_currentDownloadTask_get); return __p_currentDownloadTask ?? optionalGivenGetterValue(.p_currentDownloadTask_get, "DownloadManagerProtocolMock - stub value for currentDownloadTask was not defined") } - } - private var __p_currentDownloadTask: (DownloadDataTask)? + case (.m_loadAllOfflineProgress, .m_loadAllOfflineProgress): return .match + case (.m_deleteProgress__for_blockID(let lhsBlockid), .m_deleteProgress__for_blockID(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockID")) + return Matcher.ComparisonResult(results) + case (.m_deleteAllProgress, .m_deleteAllProgress): return .match + case (.m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let lhsBlocks, let lhsDownloadquality), .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(let rhsBlocks, let rhsDownloadquality)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsDownloadquality, rhs: rhsDownloadquality, with: matcher), lhsDownloadquality, rhsDownloadquality, "downloadQuality")) + return Matcher.ComparisonResult(results) + case (.m_nextBlockForDownloading, .m_nextBlockForDownloading): return .match - open func publisher() -> AnyPublisher { - addInvocation(.m_publisher) - let perform = methodPerformValue(.m_publisher) as? () -> Void - perform?() - var __value: AnyPublisher - do { - __value = try methodReturnValue(.m_publisher).casted() - } catch { - onFatalFailure("Stub return value not specified for publisher(). Use given") - Failure("Stub return value not specified for publisher(). Use given") - } - return __value - } + case (.m_updateDownloadState__id_idstate_stateresumeData_resumeData(let lhsId, let lhsState, let lhsResumedata), .m_updateDownloadState__id_idstate_stateresumeData_resumeData(let rhsId, let rhsState, let rhsResumedata)): + 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: lhsState, rhs: rhsState, with: matcher), lhsState, rhsState, "state")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsResumedata, rhs: rhsResumedata, with: matcher), lhsResumedata, rhsResumedata, "resumeData")) + return Matcher.ComparisonResult(results) - open func eventPublisher() -> AnyPublisher { - addInvocation(.m_eventPublisher) - let perform = methodPerformValue(.m_eventPublisher) as? () -> Void - perform?() - var __value: AnyPublisher - do { - __value = try methodReturnValue(.m_eventPublisher).casted() - } catch { - onFatalFailure("Stub return value not specified for eventPublisher(). Use given") - Failure("Stub return value not specified for eventPublisher(). Use given") - } - return __value - } + case (.m_deleteDownloadDataTask__id_id(let lhsId), .m_deleteDownloadDataTask__id_id(let rhsId)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsId, rhs: rhsId, with: matcher), lhsId, rhsId, "id")) + return Matcher.ComparisonResult(results) - 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 - } - } + case (.m_saveDownloadDataTask__task(let lhsTask), .m_saveDownloadDataTask__task(let rhsTask)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "_ task")) + return Matcher.ComparisonResult(results) - open func getDownloadTasks() -> [DownloadDataTask] { - addInvocation(.m_getDownloadTasks) - let perform = methodPerformValue(.m_getDownloadTasks) as? () -> Void - perform?() - var __value: [DownloadDataTask] - do { - __value = try methodReturnValue(.m_getDownloadTasks).casted() - } catch { - onFatalFailure("Stub return value not specified for getDownloadTasks(). Use given") - Failure("Stub return value not specified for getDownloadTasks(). Use given") - } - return __value + case (.m_downloadDataTask__for_blockId(let lhsBlockid), .m_downloadDataTask__for_blockId(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + return Matcher.ComparisonResult(results) + + case (.m_getDownloadDataTasks, .m_getDownloadDataTasks): return .match + + case (.m_getDownloadDataTasksForCourse__courseId(let lhsCourseid), .m_getDownloadDataTasksForCourse__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 + } + } + + func intValue() -> Int { + switch self { + case let .m_set__userId_userId(p0): return p0.intValue + case .m_getUserID: return 0 + case .m_publisher: return 0 + case let .m_addToDownloadQueue__tasks_tasks(p0): return p0.intValue + case let .m_saveOfflineProgress__progress_progress(p0): return p0.intValue + case let .m_loadProgress__for_blockID(p0): return p0.intValue + case .m_loadAllOfflineProgress: return 0 + case let .m_deleteProgress__for_blockID(p0): return p0.intValue + case .m_deleteAllProgress: return 0 + case let .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(p0, p1): return p0.intValue + p1.intValue + case .m_nextBlockForDownloading: return 0 + case let .m_updateDownloadState__id_idstate_stateresumeData_resumeData(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + case let .m_deleteDownloadDataTask__id_id(p0): return p0.intValue + case let .m_saveDownloadDataTask__task(p0): return p0.intValue + case let .m_downloadDataTask__for_blockId(p0): return p0.intValue + case .m_getDownloadDataTasks: return 0 + case let .m_getDownloadDataTasksForCourse__courseId(p0): return p0.intValue + } + } + func assertionName() -> String { + switch self { + case .m_set__userId_userId: return ".set(userId:)" + case .m_getUserID: return ".getUserID()" + case .m_publisher: return ".publisher()" + case .m_addToDownloadQueue__tasks_tasks: return ".addToDownloadQueue(tasks:)" + case .m_saveOfflineProgress__progress_progress: return ".saveOfflineProgress(progress:)" + case .m_loadProgress__for_blockID: return ".loadProgress(for:)" + case .m_loadAllOfflineProgress: return ".loadAllOfflineProgress()" + case .m_deleteProgress__for_blockID: return ".deleteProgress(for:)" + case .m_deleteAllProgress: return ".deleteAllProgress()" + case .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality: return ".addToDownloadQueue(blocks:downloadQuality:)" + case .m_nextBlockForDownloading: return ".nextBlockForDownloading()" + case .m_updateDownloadState__id_idstate_stateresumeData_resumeData: return ".updateDownloadState(id:state:resumeData:)" + case .m_deleteDownloadDataTask__id_id: return ".deleteDownloadDataTask(id:)" + case .m_saveDownloadDataTask__task: return ".saveDownloadDataTask(_:)" + case .m_downloadDataTask__for_blockId: return ".downloadDataTask(for:)" + case .m_getDownloadDataTasks: return ".getDownloadDataTasks()" + case .m_getDownloadDataTasksForCourse__courseId: return ".getDownloadDataTasksForCourse(_:)" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func getUserID(willReturn: Int?...) -> MethodStub { + return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func publisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadProgress(for blockID: Parameter, willReturn: OfflineProgress?...) -> MethodStub { + return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func loadAllOfflineProgress(willReturn: [OfflineProgress]...) -> MethodStub { + return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func nextBlockForDownloading(willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func downloadDataTask(for blockId: Parameter, willReturn: DownloadDataTask?...) -> MethodStub { + return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasks(willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getUserID(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Int?] = [] + let given: Given = { return Given(method: .m_getUserID, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Int?).self) + willProduce(stubber) + return given + } + public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func loadProgress(for blockID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [OfflineProgress?] = [] + let given: Given = { return Given(method: .m_loadProgress__for_blockID(`blockID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (OfflineProgress?).self) + willProduce(stubber) + return given + } + public static func loadAllOfflineProgress(willProduce: (Stubber<[OfflineProgress]>) -> Void) -> MethodStub { + let willReturn: [[OfflineProgress]] = [] + let given: Given = { return Given(method: .m_loadAllOfflineProgress, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([OfflineProgress]).self) + willProduce(stubber) + return given + } + public static func nextBlockForDownloading(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_nextBlockForDownloading, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func downloadDataTask(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [DownloadDataTask?] = [] + let given: Given = { return Given(method: .m_downloadDataTask__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (DownloadDataTask?).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func deleteDownloadDataTask(id: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func deleteDownloadDataTask(id: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_deleteDownloadDataTask__id_id(`id`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + public static func set(userId: Parameter) -> Verify { return Verify(method: .m_set__userId_userId(`userId`))} + public static func getUserID() -> Verify { return Verify(method: .m_getUserID)} + public static func publisher() -> Verify { return Verify(method: .m_publisher)} + public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>) -> Verify { return Verify(method: .m_addToDownloadQueue__tasks_tasks(`tasks`))} + public static func saveOfflineProgress(progress: Parameter) -> Verify { return Verify(method: .m_saveOfflineProgress__progress_progress(`progress`))} + public static func loadProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_loadProgress__for_blockID(`blockID`))} + public static func loadAllOfflineProgress() -> Verify { return Verify(method: .m_loadAllOfflineProgress)} + public static func deleteProgress(for blockID: Parameter) -> Verify { return Verify(method: .m_deleteProgress__for_blockID(`blockID`))} + public static func deleteAllProgress() -> Verify { return Verify(method: .m_deleteAllProgress)} + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter) -> Verify { return Verify(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`))} + public static func nextBlockForDownloading() -> Verify { return Verify(method: .m_nextBlockForDownloading)} + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter) -> Verify { return Verify(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`))} + public static func deleteDownloadDataTask(id: Parameter) -> Verify { return Verify(method: .m_deleteDownloadDataTask__id_id(`id`))} + public static func saveDownloadDataTask(_ task: Parameter) -> Verify { return Verify(method: .m_saveDownloadDataTask__task(`task`))} + public static func downloadDataTask(for blockId: Parameter) -> Verify { return Verify(method: .m_downloadDataTask__for_blockId(`blockId`))} + public static func getDownloadDataTasks() -> Verify { return Verify(method: .m_getDownloadDataTasks)} + public static func getDownloadDataTasksForCourse(_ courseId: Parameter) -> Verify { return Verify(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`))} + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func set(userId: Parameter, perform: @escaping (Int) -> Void) -> Perform { + return Perform(method: .m_set__userId_userId(`userId`), performs: perform) + } + public static func getUserID(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getUserID, performs: perform) + } + public static func publisher(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_publisher, performs: perform) + } + public static func addToDownloadQueue(tasks: Parameter<[DownloadDataTask]>, perform: @escaping ([DownloadDataTask]) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__tasks_tasks(`tasks`), performs: perform) + } + public static func saveOfflineProgress(progress: Parameter, perform: @escaping (OfflineProgress) -> Void) -> Perform { + return Perform(method: .m_saveOfflineProgress__progress_progress(`progress`), performs: perform) + } + public static func loadProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_loadProgress__for_blockID(`blockID`), performs: perform) + } + public static func loadAllOfflineProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_loadAllOfflineProgress, performs: perform) + } + public static func deleteProgress(for blockID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteProgress__for_blockID(`blockID`), performs: perform) + } + public static func deleteAllProgress(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllProgress, performs: perform) + } + public static func addToDownloadQueue(blocks: Parameter<[CourseBlock]>, downloadQuality: Parameter, perform: @escaping ([CourseBlock], DownloadQuality) -> Void) -> Perform { + return Perform(method: .m_addToDownloadQueue__blocks_blocksdownloadQuality_downloadQuality(`blocks`, `downloadQuality`), performs: perform) + } + public static func nextBlockForDownloading(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_nextBlockForDownloading, performs: perform) + } + public static func updateDownloadState(id: Parameter, state: Parameter, resumeData: Parameter, perform: @escaping (String, DownloadState, Data?) -> Void) -> Perform { + return Perform(method: .m_updateDownloadState__id_idstate_stateresumeData_resumeData(`id`, `state`, `resumeData`), performs: perform) + } + public static func deleteDownloadDataTask(id: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteDownloadDataTask__id_id(`id`), performs: perform) + } + public static func saveDownloadDataTask(_ task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { + return Perform(method: .m_saveDownloadDataTask__task(`task`), performs: perform) + } + public static func downloadDataTask(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_downloadDataTask__for_blockId(`blockId`), performs: perform) + } + public static func getDownloadDataTasks(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasks, performs: perform) + } + public static func getDownloadDataTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadDataTasksForCourse__courseId(`courseId`), 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: - CoreStorage + +open class CoreStorageMock: CoreStorage, 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 accessToken: String? { + get { invocations.append(.p_accessToken_get); return __p_accessToken ?? optionalGivenGetterValue(.p_accessToken_get, "CoreStorageMock - stub value for accessToken was not defined") } + set { invocations.append(.p_accessToken_set(.value(newValue))); __p_accessToken = newValue } + } + private var __p_accessToken: (String)? + + public var refreshToken: String? { + get { invocations.append(.p_refreshToken_get); return __p_refreshToken ?? optionalGivenGetterValue(.p_refreshToken_get, "CoreStorageMock - stub value for refreshToken was not defined") } + set { invocations.append(.p_refreshToken_set(.value(newValue))); __p_refreshToken = newValue } + } + private var __p_refreshToken: (String)? + + public var pushToken: String? { + get { invocations.append(.p_pushToken_get); return __p_pushToken ?? optionalGivenGetterValue(.p_pushToken_get, "CoreStorageMock - stub value for pushToken was not defined") } + set { invocations.append(.p_pushToken_set(.value(newValue))); __p_pushToken = newValue } + } + private var __p_pushToken: (String)? + + public var appleSignFullName: String? { + get { invocations.append(.p_appleSignFullName_get); return __p_appleSignFullName ?? optionalGivenGetterValue(.p_appleSignFullName_get, "CoreStorageMock - stub value for appleSignFullName was not defined") } + set { invocations.append(.p_appleSignFullName_set(.value(newValue))); __p_appleSignFullName = newValue } + } + private var __p_appleSignFullName: (String)? + + public var appleSignEmail: String? { + get { invocations.append(.p_appleSignEmail_get); return __p_appleSignEmail ?? optionalGivenGetterValue(.p_appleSignEmail_get, "CoreStorageMock - stub value for appleSignEmail was not defined") } + set { invocations.append(.p_appleSignEmail_set(.value(newValue))); __p_appleSignEmail = newValue } + } + private var __p_appleSignEmail: (String)? + + public var cookiesDate: Date? { + get { invocations.append(.p_cookiesDate_get); return __p_cookiesDate ?? optionalGivenGetterValue(.p_cookiesDate_get, "CoreStorageMock - stub value for cookiesDate was not defined") } + set { invocations.append(.p_cookiesDate_set(.value(newValue))); __p_cookiesDate = newValue } + } + private var __p_cookiesDate: (Date)? + + public var reviewLastShownVersion: String? { + get { invocations.append(.p_reviewLastShownVersion_get); return __p_reviewLastShownVersion ?? optionalGivenGetterValue(.p_reviewLastShownVersion_get, "CoreStorageMock - stub value for reviewLastShownVersion was not defined") } + set { invocations.append(.p_reviewLastShownVersion_set(.value(newValue))); __p_reviewLastShownVersion = newValue } + } + private var __p_reviewLastShownVersion: (String)? + + public var lastReviewDate: Date? { + get { invocations.append(.p_lastReviewDate_get); return __p_lastReviewDate ?? optionalGivenGetterValue(.p_lastReviewDate_get, "CoreStorageMock - stub value for lastReviewDate was not defined") } + set { invocations.append(.p_lastReviewDate_set(.value(newValue))); __p_lastReviewDate = newValue } + } + private var __p_lastReviewDate: (Date)? + + public var user: DataLayer.User? { + get { invocations.append(.p_user_get); return __p_user ?? optionalGivenGetterValue(.p_user_get, "CoreStorageMock - stub value for user was not defined") } + set { invocations.append(.p_user_set(.value(newValue))); __p_user = newValue } + } + private var __p_user: (DataLayer.User)? + + public var userSettings: UserSettings? { + get { invocations.append(.p_userSettings_get); return __p_userSettings ?? optionalGivenGetterValue(.p_userSettings_get, "CoreStorageMock - stub value for userSettings was not defined") } + set { invocations.append(.p_userSettings_set(.value(newValue))); __p_userSettings = newValue } + } + private var __p_userSettings: (UserSettings)? + + public var resetAppSupportDirectoryUserData: Bool? { + get { invocations.append(.p_resetAppSupportDirectoryUserData_get); return __p_resetAppSupportDirectoryUserData ?? optionalGivenGetterValue(.p_resetAppSupportDirectoryUserData_get, "CoreStorageMock - stub value for resetAppSupportDirectoryUserData was not defined") } + set { invocations.append(.p_resetAppSupportDirectoryUserData_set(.value(newValue))); __p_resetAppSupportDirectoryUserData = newValue } + } + private var __p_resetAppSupportDirectoryUserData: (Bool)? + + public var useRelativeDates: Bool { + get { invocations.append(.p_useRelativeDates_get); return __p_useRelativeDates ?? givenGetterValue(.p_useRelativeDates_get, "CoreStorageMock - stub value for useRelativeDates was not defined") } + set { invocations.append(.p_useRelativeDates_set(.value(newValue))); __p_useRelativeDates = newValue } + } + private var __p_useRelativeDates: (Bool)? + + + + + + open func clear() { + addInvocation(.m_clear) + let perform = methodPerformValue(.m_clear) as? () -> Void + perform?() + } + + + fileprivate enum MethodType { + case m_clear + case p_accessToken_get + case p_accessToken_set(Parameter) + case p_refreshToken_get + case p_refreshToken_set(Parameter) + case p_pushToken_get + case p_pushToken_set(Parameter) + case p_appleSignFullName_get + case p_appleSignFullName_set(Parameter) + case p_appleSignEmail_get + case p_appleSignEmail_set(Parameter) + case p_cookiesDate_get + case p_cookiesDate_set(Parameter) + case p_reviewLastShownVersion_get + case p_reviewLastShownVersion_set(Parameter) + case p_lastReviewDate_get + case p_lastReviewDate_set(Parameter) + case p_user_get + case p_user_set(Parameter) + case p_userSettings_get + case p_userSettings_set(Parameter) + case p_resetAppSupportDirectoryUserData_get + case p_resetAppSupportDirectoryUserData_set(Parameter) + case p_useRelativeDates_get + case p_useRelativeDates_set(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_clear, .m_clear): return .match + case (.p_accessToken_get,.p_accessToken_get): return Matcher.ComparisonResult.match + case (.p_accessToken_set(let left),.p_accessToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_refreshToken_get,.p_refreshToken_get): return Matcher.ComparisonResult.match + case (.p_refreshToken_set(let left),.p_refreshToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_pushToken_get,.p_pushToken_get): return Matcher.ComparisonResult.match + case (.p_pushToken_set(let left),.p_pushToken_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_appleSignFullName_get,.p_appleSignFullName_get): return Matcher.ComparisonResult.match + case (.p_appleSignFullName_set(let left),.p_appleSignFullName_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_appleSignEmail_get,.p_appleSignEmail_get): return Matcher.ComparisonResult.match + case (.p_appleSignEmail_set(let left),.p_appleSignEmail_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_cookiesDate_get,.p_cookiesDate_get): return Matcher.ComparisonResult.match + case (.p_cookiesDate_set(let left),.p_cookiesDate_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_reviewLastShownVersion_get,.p_reviewLastShownVersion_get): return Matcher.ComparisonResult.match + case (.p_reviewLastShownVersion_set(let left),.p_reviewLastShownVersion_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_lastReviewDate_get,.p_lastReviewDate_get): return Matcher.ComparisonResult.match + case (.p_lastReviewDate_set(let left),.p_lastReviewDate_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_user_get,.p_user_get): return Matcher.ComparisonResult.match + case (.p_user_set(let left),.p_user_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_userSettings_get,.p_userSettings_get): return Matcher.ComparisonResult.match + case (.p_userSettings_set(let left),.p_userSettings_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_resetAppSupportDirectoryUserData_get,.p_resetAppSupportDirectoryUserData_get): return Matcher.ComparisonResult.match + case (.p_resetAppSupportDirectoryUserData_set(let left),.p_resetAppSupportDirectoryUserData_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_useRelativeDates_get,.p_useRelativeDates_get): return Matcher.ComparisonResult.match + case (.p_useRelativeDates_set(let left),.p_useRelativeDates_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 .m_clear: return 0 + case .p_accessToken_get: return 0 + case .p_accessToken_set(let newValue): return newValue.intValue + case .p_refreshToken_get: return 0 + case .p_refreshToken_set(let newValue): return newValue.intValue + case .p_pushToken_get: return 0 + case .p_pushToken_set(let newValue): return newValue.intValue + case .p_appleSignFullName_get: return 0 + case .p_appleSignFullName_set(let newValue): return newValue.intValue + case .p_appleSignEmail_get: return 0 + case .p_appleSignEmail_set(let newValue): return newValue.intValue + case .p_cookiesDate_get: return 0 + case .p_cookiesDate_set(let newValue): return newValue.intValue + case .p_reviewLastShownVersion_get: return 0 + case .p_reviewLastShownVersion_set(let newValue): return newValue.intValue + case .p_lastReviewDate_get: return 0 + case .p_lastReviewDate_set(let newValue): return newValue.intValue + case .p_user_get: return 0 + case .p_user_set(let newValue): return newValue.intValue + case .p_userSettings_get: return 0 + case .p_userSettings_set(let newValue): return newValue.intValue + case .p_resetAppSupportDirectoryUserData_get: return 0 + case .p_resetAppSupportDirectoryUserData_set(let newValue): return newValue.intValue + case .p_useRelativeDates_get: return 0 + case .p_useRelativeDates_set(let newValue): return newValue.intValue + } + } + func assertionName() -> String { + switch self { + case .m_clear: return ".clear()" + case .p_accessToken_get: return "[get] .accessToken" + case .p_accessToken_set: return "[set] .accessToken" + case .p_refreshToken_get: return "[get] .refreshToken" + case .p_refreshToken_set: return "[set] .refreshToken" + case .p_pushToken_get: return "[get] .pushToken" + case .p_pushToken_set: return "[set] .pushToken" + case .p_appleSignFullName_get: return "[get] .appleSignFullName" + case .p_appleSignFullName_set: return "[set] .appleSignFullName" + case .p_appleSignEmail_get: return "[get] .appleSignEmail" + case .p_appleSignEmail_set: return "[set] .appleSignEmail" + case .p_cookiesDate_get: return "[get] .cookiesDate" + case .p_cookiesDate_set: return "[set] .cookiesDate" + case .p_reviewLastShownVersion_get: return "[get] .reviewLastShownVersion" + case .p_reviewLastShownVersion_set: return "[set] .reviewLastShownVersion" + case .p_lastReviewDate_get: return "[get] .lastReviewDate" + case .p_lastReviewDate_set: return "[set] .lastReviewDate" + case .p_user_get: return "[get] .user" + case .p_user_set: return "[set] .user" + case .p_userSettings_get: return "[get] .userSettings" + case .p_userSettings_set: return "[set] .userSettings" + case .p_resetAppSupportDirectoryUserData_get: return "[get] .resetAppSupportDirectoryUserData" + case .p_resetAppSupportDirectoryUserData_set: return "[set] .resetAppSupportDirectoryUserData" + case .p_useRelativeDates_get: return "[get] .useRelativeDates" + case .p_useRelativeDates_set: return "[set] .useRelativeDates" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func accessToken(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_accessToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func refreshToken(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_refreshToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func pushToken(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_pushToken_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appleSignFullName(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_appleSignFullName_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func appleSignEmail(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_appleSignEmail_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func cookiesDate(getter defaultValue: Date?...) -> PropertyStub { + return Given(method: .p_cookiesDate_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func reviewLastShownVersion(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_reviewLastShownVersion_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func lastReviewDate(getter defaultValue: Date?...) -> PropertyStub { + return Given(method: .p_lastReviewDate_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func user(getter defaultValue: DataLayer.User?...) -> PropertyStub { + return Given(method: .p_user_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func userSettings(getter defaultValue: UserSettings?...) -> PropertyStub { + return Given(method: .p_userSettings_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func resetAppSupportDirectoryUserData(getter defaultValue: Bool?...) -> PropertyStub { + return Given(method: .p_resetAppSupportDirectoryUserData_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func useRelativeDates(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_useRelativeDates_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static func clear() -> Verify { return Verify(method: .m_clear)} + public static var accessToken: Verify { return Verify(method: .p_accessToken_get) } + public static func accessToken(set newValue: Parameter) -> Verify { return Verify(method: .p_accessToken_set(newValue)) } + public static var refreshToken: Verify { return Verify(method: .p_refreshToken_get) } + public static func refreshToken(set newValue: Parameter) -> Verify { return Verify(method: .p_refreshToken_set(newValue)) } + public static var pushToken: Verify { return Verify(method: .p_pushToken_get) } + public static func pushToken(set newValue: Parameter) -> Verify { return Verify(method: .p_pushToken_set(newValue)) } + public static var appleSignFullName: Verify { return Verify(method: .p_appleSignFullName_get) } + public static func appleSignFullName(set newValue: Parameter) -> Verify { return Verify(method: .p_appleSignFullName_set(newValue)) } + public static var appleSignEmail: Verify { return Verify(method: .p_appleSignEmail_get) } + public static func appleSignEmail(set newValue: Parameter) -> Verify { return Verify(method: .p_appleSignEmail_set(newValue)) } + public static var cookiesDate: Verify { return Verify(method: .p_cookiesDate_get) } + public static func cookiesDate(set newValue: Parameter) -> Verify { return Verify(method: .p_cookiesDate_set(newValue)) } + public static var reviewLastShownVersion: Verify { return Verify(method: .p_reviewLastShownVersion_get) } + public static func reviewLastShownVersion(set newValue: Parameter) -> Verify { return Verify(method: .p_reviewLastShownVersion_set(newValue)) } + public static var lastReviewDate: Verify { return Verify(method: .p_lastReviewDate_get) } + public static func lastReviewDate(set newValue: Parameter) -> Verify { return Verify(method: .p_lastReviewDate_set(newValue)) } + public static var user: Verify { return Verify(method: .p_user_get) } + public static func user(set newValue: Parameter) -> Verify { return Verify(method: .p_user_set(newValue)) } + public static var userSettings: Verify { return Verify(method: .p_userSettings_get) } + public static func userSettings(set newValue: Parameter) -> Verify { return Verify(method: .p_userSettings_set(newValue)) } + public static var resetAppSupportDirectoryUserData: Verify { return Verify(method: .p_resetAppSupportDirectoryUserData_get) } + public static func resetAppSupportDirectoryUserData(set newValue: Parameter) -> Verify { return Verify(method: .p_resetAppSupportDirectoryUserData_set(newValue)) } + public static var useRelativeDates: Verify { return Verify(method: .p_useRelativeDates_get) } + public static func useRelativeDates(set newValue: Parameter) -> Verify { return Verify(method: .p_useRelativeDates_set(newValue)) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + public static func clear(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_clear, 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 { + 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 currentDownloadTask: DownloadDataTask? { + get { invocations.append(.p_currentDownloadTask_get); return __p_currentDownloadTask ?? optionalGivenGetterValue(.p_currentDownloadTask_get, "DownloadManagerProtocolMock - stub value for currentDownloadTask was not defined") } + } + private var __p_currentDownloadTask: (DownloadDataTask)? + + + + + + open func publisher() -> AnyPublisher { + addInvocation(.m_publisher) + let perform = methodPerformValue(.m_publisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_publisher).casted() + } catch { + onFatalFailure("Stub return value not specified for publisher(). Use given") + Failure("Stub return value not specified for publisher(). Use given") + } + return __value + } + + open func eventPublisher() -> AnyPublisher { + addInvocation(.m_eventPublisher) + let perform = methodPerformValue(.m_eventPublisher) as? () -> Void + perform?() + var __value: AnyPublisher + do { + __value = try methodReturnValue(.m_eventPublisher).casted() + } catch { + onFatalFailure("Stub return value not specified for eventPublisher(). Use given") + Failure("Stub return value not specified for eventPublisher(). Use given") + } + 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 + perform?() + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadTasks).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadTasks(). Use given") + Failure("Stub return value not specified for getDownloadTasks(). Use given") + } + return __value + } + + open func getDownloadTasksForCourse(_ courseId: String) -> [DownloadDataTask] { + addInvocation(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [DownloadDataTask] + do { + __value = try methodReturnValue(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))).casted() + } catch { + onFatalFailure("Stub return value not specified for getDownloadTasksForCourse(_ courseId: String). Use given") + Failure("Stub return value not specified for getDownloadTasksForCourse(_ courseId: String). Use given") + } + return __value + } + + open func cancelDownloading(courseId: String, blocks: [CourseBlock]) throws { + addInvocation(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))) as? (String, [CourseBlock]) -> Void + perform?(`courseId`, `blocks`) + do { + _ = try methodReturnValue(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func cancelDownloading(task: DownloadDataTask) throws { + addInvocation(.m_cancelDownloading__task_task(Parameter.value(`task`))) + let perform = methodPerformValue(.m_cancelDownloading__task_task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void + perform?(`task`) + do { + _ = try methodReturnValue(.m_cancelDownloading__task_task(Parameter.value(`task`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func cancelDownloading(courseId: String) throws { + addInvocation(.m_cancelDownloading__courseId_courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_cancelDownloading__courseId_courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + do { + _ = try methodReturnValue(.m_cancelDownloading__courseId_courseId(Parameter.value(`courseId`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + 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 + perform?(`blocks`) + } + + open func deleteAllFiles() { + addInvocation(.m_deleteAllFiles) + let perform = methodPerformValue(.m_deleteAllFiles) as? () -> Void + perform?() + } + + open func fileUrl(for blockId: String) -> URL? { + addInvocation(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) + let perform = methodPerformValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void + perform?(`blockId`) + var __value: URL? = nil + do { + __value = try methodReturnValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))).casted() + } catch { + // do nothing + } + return __value + } + + open func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] { + addInvocation(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) + let perform = methodPerformValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) as? ([CourseSequential]) -> Void + perform?(`sequentials`) + var __value: [CourseSequential] + do { + __value = try methodReturnValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))).casted() + } catch { + onFatalFailure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + Failure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") + } + return __value + } + + open func resumeDownloading() throws { + addInvocation(.m_resumeDownloading) + let perform = methodPerformValue(.m_resumeDownloading) as? () -> Void + perform?() + do { + _ = try methodReturnValue(.m_resumeDownloading).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } + } + + open func isLargeVideosSize(blocks: [CourseBlock]) -> Bool { + addInvocation(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) + let perform = methodPerformValue(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void + perform?(`blocks`) + var __value: Bool + do { + __value = try methodReturnValue(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() + } catch { + onFatalFailure("Stub return value not specified for isLargeVideosSize(blocks: [CourseBlock]). Use given") + Failure("Stub return value not specified for isLargeVideosSize(blocks: [CourseBlock]). Use given") + } + return __value + } + + open func removeAppSupportDirectoryUnusedContent() { + addInvocation(.m_removeAppSupportDirectoryUnusedContent) + let perform = methodPerformValue(.m_removeAppSupportDirectoryUnusedContent) as? () -> Void + perform?() + } + + + 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_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>) + case m_resumeDownloading + case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) + case m_removeAppSupportDirectoryUnusedContent + case p_currentDownloadTask_get + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_publisher, .m_publisher): return .match + + case (.m_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)): + 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_cancelDownloading__courseId_courseIdblocks_blocks(let lhsCourseid, let lhsBlocks), .m_cancelDownloading__courseId_courseIdblocks_blocks(let rhsCourseid, let rhsBlocks)): + 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: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + return Matcher.ComparisonResult(results) + + case (.m_cancelDownloading__task_task(let lhsTask), .m_cancelDownloading__task_task(let rhsTask)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "task")) + return Matcher.ComparisonResult(results) + + case (.m_cancelDownloading__courseId_courseId(let lhsCourseid), .m_cancelDownloading__courseId_courseId(let rhsCourseid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) + return Matcher.ComparisonResult(results) + + case (.m_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")) + return Matcher.ComparisonResult(results) + + case (.m_deleteAllFiles, .m_deleteAllFiles): return .match + + case (.m_fileUrl__for_blockId(let lhsBlockid), .m_fileUrl__for_blockId(let rhsBlockid)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) + return Matcher.ComparisonResult(results) + + case (.m_updateUnzippedFileSize__for_sequentials(let lhsSequentials), .m_updateUnzippedFileSize__for_sequentials(let rhsSequentials)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSequentials, rhs: rhsSequentials, with: matcher), lhsSequentials, rhsSequentials, "for sequentials")) + 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_removeAppSupportDirectoryUnusedContent, .m_removeAppSupportDirectoryUnusedContent): return .match + case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match + default: return .none + } + } + + func intValue() -> Int { + 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_updateUnzippedFileSize__for_sequentials(p0): return p0.intValue + case .m_resumeDownloading: return 0 + case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue + case .m_removeAppSupportDirectoryUnusedContent: return 0 + case .p_currentDownloadTask_get: return 0 + } + } + func assertionName() -> String { + 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_updateUnzippedFileSize__for_sequentials: return ".updateUnzippedFileSize(for:)" + case .m_resumeDownloading: return ".resumeDownloading()" + case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" + case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" + case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func currentDownloadTask(getter defaultValue: DownloadDataTask?...) -> PropertyStub { + return Given(method: .p_currentDownloadTask_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + public static func publisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func eventPublisher(willReturn: AnyPublisher...) -> MethodStub { + return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadTasks(willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { + return Given(method: .m_getDownloadTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { + return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willReturn: [CourseSequential]...) -> MethodStub { + return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willReturn: Bool...) -> MethodStub { + return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func eventPublisher(willProduce: (Stubber>) -> Void) -> MethodStub { + let willReturn: [AnyPublisher] = [] + let given: Given = { return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (AnyPublisher).self) + willProduce(stubber) + return given + } + public static func getDownloadTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { + let willReturn: [[DownloadDataTask]] = [] + let given: Given = { return Given(method: .m_getDownloadTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([DownloadDataTask]).self) + willProduce(stubber) + return given + } + public static func fileUrl(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [URL?] = [] + let given: Given = { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (URL?).self) + willProduce(stubber) + return given + } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willProduce: (Stubber<[CourseSequential]>) -> Void) -> MethodStub { + let willReturn: [[CourseSequential]] = [] + let given: Given = { return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseSequential]).self) + willProduce(stubber) + return given + } + public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [Bool] = [] + let given: Given = { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (Bool).self) + willProduce(stubber) + return given + } + 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) })) + } + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func cancelDownloading(task: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelDownloading__task_task(`task`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func cancelDownloading(task: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_cancelDownloading__task_task(`task`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func cancelDownloading(courseId: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelDownloading__courseId_courseId(`courseId`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func cancelDownloading(courseId: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_cancelDownloading__courseId_courseId(`courseId`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func cancelAllDownloading(willThrow: Error...) -> MethodStub { + return Given(method: .m_cancelAllDownloading, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func cancelAllDownloading(willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + 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 + } + public static func resumeDownloading(willThrow: Error...) -> MethodStub { + return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func resumeDownloading(willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + } + + public struct Verify { + fileprivate var method: MethodType + + 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 updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>) -> Verify { return Verify(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`))} + 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 func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} + public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } - open func getDownloadTasksForCourse(_ courseId: String) -> [DownloadDataTask] { - addInvocation(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))) - let perform = methodPerformValue(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))) as? (String) -> Void - perform?(`courseId`) - var __value: [DownloadDataTask] - do { - __value = try methodReturnValue(.m_getDownloadTasksForCourse__courseId(Parameter.value(`courseId`))).casted() - } catch { - onFatalFailure("Stub return value not specified for getDownloadTasksForCourse(_ courseId: String). Use given") - Failure("Stub return value not specified for getDownloadTasksForCourse(_ courseId: String). Use given") - } - return __value - } + public struct Perform { + fileprivate var method: MethodType + var performs: Any - open func cancelDownloading(courseId: String, blocks: [CourseBlock]) throws { - addInvocation(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))) - let perform = methodPerformValue(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))) as? (String, [CourseBlock]) -> Void - perform?(`courseId`, `blocks`) - do { - _ = try methodReturnValue(.m_cancelDownloading__courseId_courseIdblocks_blocks(Parameter.value(`courseId`), Parameter<[CourseBlock]>.value(`blocks`))).casted() as Void - } catch MockError.notStubed { - // do nothing - } catch { - throw error - } + public static func publisher(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_publisher, performs: perform) + } + 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) + } + public static func getDownloadTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getDownloadTasksForCourse__courseId(`courseId`), performs: perform) + } + public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, perform: @escaping (String, [CourseBlock]) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), performs: perform) + } + public static func cancelDownloading(task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__task_task(`task`), performs: perform) + } + public static func cancelDownloading(courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_cancelDownloading__courseId_courseId(`courseId`), performs: perform) + } + public static func 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) + } + public static func deleteAllFiles(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllFiles, performs: perform) + } + public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) + } + public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, perform: @escaping ([CourseSequential]) -> Void) -> Perform { + return Perform(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), 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 removeAppSupportDirectoryUnusedContent(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_removeAppSupportDirectoryUnusedContent, performs: perform) + } } - open func cancelDownloading(task: DownloadDataTask) throws { - addInvocation(.m_cancelDownloading__task_task(Parameter.value(`task`))) - let perform = methodPerformValue(.m_cancelDownloading__task_task(Parameter.value(`task`))) as? (DownloadDataTask) -> Void - perform?(`task`) - do { - _ = try methodReturnValue(.m_cancelDownloading__task_task(Parameter.value(`task`))).casted() as Void - } catch MockError.notStubed { - // do nothing - } catch { - throw error - } + public func given(_ method: Given) { + methodReturnValues.append(method) } - open func cancelDownloading(courseId: String) throws { - addInvocation(.m_cancelDownloading__courseId_courseId(Parameter.value(`courseId`))) - let perform = methodPerformValue(.m_cancelDownloading__courseId_courseId(Parameter.value(`courseId`))) as? (String) -> Void - perform?(`courseId`) - do { - _ = try methodReturnValue(.m_cancelDownloading__courseId_courseId(Parameter.value(`courseId`))).casted() as Void - } catch MockError.notStubed { - // do nothing - } catch { - throw error - } + public func perform(_ method: Perform) { + methodPerformValues.append(method) + methodPerformValues.sort { $0.method.intValue() < $1.method.intValue() } } - 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 - } + 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) } - 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 - perform?(`blocks`) + private func addInvocation(_ call: MethodType) { + self.queue.sync { invocations.append(call) } } - - open func deleteAllFiles() { - addInvocation(.m_deleteAllFiles) - let perform = methodPerformValue(.m_deleteAllFiles) as? () -> Void - perform?() + 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 } - - open func fileUrl(for blockId: String) -> URL? { - addInvocation(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) - let perform = methodPerformValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))) as? (String) -> Void - perform?(`blockId`) - var __value: URL? = nil - do { - __value = try methodReturnValue(.m_fileUrl__for_blockId(Parameter.value(`blockId`))).casted() - } catch { - // do nothing - } - return __value + 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 } - - open func updateUnzippedFileSize(for sequentials: [CourseSequential]) -> [CourseSequential] { - addInvocation(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) - let perform = methodPerformValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))) as? ([CourseSequential]) -> Void - perform?(`sequentials`) - var __value: [CourseSequential] - do { - __value = try methodReturnValue(.m_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>.value(`sequentials`))).casted() - } catch { - onFatalFailure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") - Failure("Stub return value not specified for updateUnzippedFileSize(for sequentials: [CourseSequential]). Use given") - } - return __value + 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 } } - - 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 - } + private func matchingCalls(_ method: Verify, file: StaticString?, line: UInt?) -> Int { + return matchingCalls(method.method, file: file, line: line).count } - - open func isLargeVideosSize(blocks: [CourseBlock]) -> Bool { - addInvocation(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) - let perform = methodPerformValue(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))) as? ([CourseBlock]) -> Void - perform?(`blocks`) - var __value: Bool - do { - __value = try methodReturnValue(.m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>.value(`blocks`))).casted() - } catch { - onFatalFailure("Stub return value not specified for isLargeVideosSize(blocks: [CourseBlock]). Use given") - Failure("Stub return value not specified for isLargeVideosSize(blocks: [CourseBlock]). Use given") - } - return __value + private func givenGetterValue(_ method: MethodType, _ message: String) -> T { + do { + return try methodReturnValue(method).casted() + } catch { + onFatalFailure(message) + Failure(message) + } } - - open func removeAppSupportDirectoryUnusedContent() { - addInvocation(.m_removeAppSupportDirectoryUnusedContent) - let perform = methodPerformValue(.m_removeAppSupportDirectoryUnusedContent) as? () -> Void - perform?() + 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: - OfflineSyncInteractorProtocol - 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_updateUnzippedFileSize__for_sequentials(Parameter<[CourseSequential]>) - case m_resumeDownloading - case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) - case m_removeAppSupportDirectoryUnusedContent - case p_currentDownloadTask_get - - static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { - switch (lhs, rhs) { - case (.m_publisher, .m_publisher): return .match - - case (.m_eventPublisher, .m_eventPublisher): return .match +open class OfflineSyncInteractorProtocolMock: OfflineSyncInteractorProtocol, 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 + } - 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) + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst - case (.m_getDownloadTasks, .m_getDownloadTasks): return .match + 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? - case (.m_getDownloadTasksForCourse__courseId(let lhsCourseid), .m_getDownloadTasksForCourse__courseId(let rhsCourseid)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "_ courseId")) - return Matcher.ComparisonResult(results) + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given - case (.m_cancelDownloading__courseId_courseIdblocks_blocks(let lhsCourseid, let lhsBlocks), .m_cancelDownloading__courseId_courseIdblocks_blocks(let rhsCourseid, let rhsBlocks)): - 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: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) - return Matcher.ComparisonResult(results) + /// 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 + } - case (.m_cancelDownloading__task_task(let lhsTask), .m_cancelDownloading__task_task(let rhsTask)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsTask, rhs: rhsTask, with: matcher), lhsTask, rhsTask, "task")) - return Matcher.ComparisonResult(results) + /// 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 = [] } + } - case (.m_cancelDownloading__courseId_courseId(let lhsCourseid), .m_cancelDownloading__courseId_courseId(let rhsCourseid)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseId")) - return Matcher.ComparisonResult(results) - case (.m_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")) - return Matcher.ComparisonResult(results) - case (.m_deleteAllFiles, .m_deleteAllFiles): return .match - case (.m_fileUrl__for_blockId(let lhsBlockid), .m_fileUrl__for_blockId(let rhsBlockid)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlockid, rhs: rhsBlockid, with: matcher), lhsBlockid, rhsBlockid, "for blockId")) - return Matcher.ComparisonResult(results) + open func submitOfflineProgress(courseID: String, blockID: String, data: String) throws -> Bool { + addInvocation(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) + let perform = methodPerformValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) as? (String, String, String) -> Void + perform?(`courseID`, `blockID`, `data`) + var __value: Bool + do { + __value = try methodReturnValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + Failure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") + } catch { + throw error + } + return __value + } - case (.m_updateUnzippedFileSize__for_sequentials(let lhsSequentials), .m_updateUnzippedFileSize__for_sequentials(let rhsSequentials)): - var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSequentials, rhs: rhsSequentials, with: matcher), lhsSequentials, rhsSequentials, "for sequentials")) - return Matcher.ComparisonResult(results) - case (.m_resumeDownloading, .m_resumeDownloading): return .match + fileprivate enum MethodType { + case m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter, Parameter, Parameter) - case (.m_isLargeVideosSize__blocks_blocks(let lhsBlocks), .m_isLargeVideosSize__blocks_blocks(let rhsBlocks)): + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { + case (.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let lhsCourseid, let lhsBlockid, let lhsData), .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let rhsCourseid, let rhsBlockid, let rhsData)): var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) + 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: lhsData, rhs: rhsData, with: matcher), lhsData, rhsData, "data")) return Matcher.ComparisonResult(results) - - case (.m_removeAppSupportDirectoryUnusedContent, .m_removeAppSupportDirectoryUnusedContent): return .match - case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match - default: return .none } } func intValue() -> Int { 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_updateUnzippedFileSize__for_sequentials(p0): return p0.intValue - case .m_resumeDownloading: return 0 - case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue - case .m_removeAppSupportDirectoryUnusedContent: return 0 - case .p_currentDownloadTask_get: return 0 - } - } - func assertionName() -> String { - 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_updateUnzippedFileSize__for_sequentials: return ".updateUnzippedFileSize(for:)" - case .m_resumeDownloading: return ".resumeDownloading()" - case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" - case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" - case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" + case let .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue } } - } - - open class Given: StubbedMethod { - fileprivate var method: MethodType - - private init(method: MethodType, products: [StubProduct]) { - self.method = method - super.init(products) - } - - public static func currentDownloadTask(getter defaultValue: DownloadDataTask?...) -> PropertyStub { - return Given(method: .p_currentDownloadTask_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } - - public static func publisher(willReturn: AnyPublisher...) -> MethodStub { - return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) - } - public static func eventPublisher(willReturn: AnyPublisher...) -> MethodStub { - return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) - } - public static func getDownloadTasks(willReturn: [DownloadDataTask]...) -> MethodStub { - return Given(method: .m_getDownloadTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) - } - public static func getDownloadTasksForCourse(_ courseId: Parameter, willReturn: [DownloadDataTask]...) -> MethodStub { - return Given(method: .m_getDownloadTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) - } - public static func fileUrl(for blockId: Parameter, willReturn: URL?...) -> MethodStub { - return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) - } - public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willReturn: [CourseSequential]...) -> MethodStub { - return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) - } - public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willReturn: Bool...) -> MethodStub { - return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) - } - public static func publisher(willProduce: (Stubber>) -> Void) -> MethodStub { - let willReturn: [AnyPublisher] = [] - let given: Given = { return Given(method: .m_publisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (AnyPublisher).self) - willProduce(stubber) - return given - } - public static func eventPublisher(willProduce: (Stubber>) -> Void) -> MethodStub { - let willReturn: [AnyPublisher] = [] - let given: Given = { return Given(method: .m_eventPublisher, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (AnyPublisher).self) - willProduce(stubber) - return given - } - public static func getDownloadTasks(willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { - let willReturn: [[DownloadDataTask]] = [] - let given: Given = { return Given(method: .m_getDownloadTasks, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: ([DownloadDataTask]).self) - willProduce(stubber) - return given - } - public static func getDownloadTasksForCourse(_ courseId: Parameter, willProduce: (Stubber<[DownloadDataTask]>) -> Void) -> MethodStub { - let willReturn: [[DownloadDataTask]] = [] - let given: Given = { return Given(method: .m_getDownloadTasksForCourse__courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: ([DownloadDataTask]).self) - willProduce(stubber) - return given - } - public static func fileUrl(for blockId: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { - let willReturn: [URL?] = [] - let given: Given = { return Given(method: .m_fileUrl__for_blockId(`blockId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (URL?).self) - willProduce(stubber) - return given - } - public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, willProduce: (Stubber<[CourseSequential]>) -> Void) -> MethodStub { - let willReturn: [[CourseSequential]] = [] - let given: Given = { return Given(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: ([CourseSequential]).self) - willProduce(stubber) - return given - } - public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, willProduce: (Stubber) -> Void) -> MethodStub { - let willReturn: [Bool] = [] - let given: Given = { return Given(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (Bool).self) - willProduce(stubber) - return given - } - 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) })) - } - public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, willProduce: (StubberThrows) -> Void) -> MethodStub { - let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (Void).self) - willProduce(stubber) - return given - } - public static func cancelDownloading(task: Parameter, willThrow: Error...) -> MethodStub { - return Given(method: .m_cancelDownloading__task_task(`task`), products: willThrow.map({ StubProduct.throw($0) })) - } - public static func cancelDownloading(task: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { - let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_cancelDownloading__task_task(`task`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (Void).self) - willProduce(stubber) - return given - } - public static func cancelDownloading(courseId: Parameter, willThrow: Error...) -> MethodStub { - return Given(method: .m_cancelDownloading__courseId_courseId(`courseId`), products: willThrow.map({ StubProduct.throw($0) })) - } - public static func cancelDownloading(courseId: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { - let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_cancelDownloading__courseId_courseId(`courseId`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (Void).self) - willProduce(stubber) - return given + func assertionName() -> String { + switch self { + case .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data: return ".submitOfflineProgress(courseID:blockID:data:)" + } } - public static func cancelAllDownloading(willThrow: Error...) -> MethodStub { - return Given(method: .m_cancelAllDownloading, products: willThrow.map({ StubProduct.throw($0) })) + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) } - public static func cancelAllDownloading(willProduce: (StubberThrows) -> Void) -> MethodStub { - let willThrow: [Error] = [] - 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 + + + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willReturn: Bool...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func resumeDownloading(willThrow: Error...) -> MethodStub { - return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) } - public static func resumeDownloading(willProduce: (StubberThrows) -> Void) -> MethodStub { + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_resumeDownloading, products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (Void).self) + let given: Given = { return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Bool).self) willProduce(stubber) return given } @@ -3049,76 +4202,15 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public struct Verify { fileprivate var method: MethodType - 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 updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>) -> Verify { return Verify(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`))} - 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 func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} - public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter) -> Verify { return Verify(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`))} } public struct Perform { fileprivate var method: MethodType var performs: Any - public static func publisher(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_publisher, performs: perform) - } - 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) - } - public static func getDownloadTasksForCourse(_ courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { - return Perform(method: .m_getDownloadTasksForCourse__courseId(`courseId`), performs: perform) - } - public static func cancelDownloading(courseId: Parameter, blocks: Parameter<[CourseBlock]>, perform: @escaping (String, [CourseBlock]) -> Void) -> Perform { - return Perform(method: .m_cancelDownloading__courseId_courseIdblocks_blocks(`courseId`, `blocks`), performs: perform) - } - public static func cancelDownloading(task: Parameter, perform: @escaping (DownloadDataTask) -> Void) -> Perform { - return Perform(method: .m_cancelDownloading__task_task(`task`), performs: perform) - } - public static func cancelDownloading(courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { - return Perform(method: .m_cancelDownloading__courseId_courseId(`courseId`), performs: perform) - } - public static func 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) - } - public static func deleteAllFiles(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_deleteAllFiles, performs: perform) - } - public static func fileUrl(for blockId: Parameter, perform: @escaping (String) -> Void) -> Perform { - return Perform(method: .m_fileUrl__for_blockId(`blockId`), performs: perform) - } - public static func updateUnzippedFileSize(for sequentials: Parameter<[CourseSequential]>, perform: @escaping ([CourseSequential]) -> Void) -> Perform { - return Perform(method: .m_updateUnzippedFileSize__for_sequentials(`sequentials`), 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 removeAppSupportDirectoryUnusedContent(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_removeAppSupportDirectoryUnusedContent, performs: perform) + public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { + return Perform(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), performs: perform) } } @@ -3195,89 +4287,271 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { } } -// MARK: - OfflineSyncInteractorProtocol +// MARK: - ProfileAnalytics + +open class ProfileAnalyticsMock: ProfileAnalytics, Mock { + public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { + SwiftyMockyTestObserver.setup() + self.sequencingPolicy = sequencingPolicy + self.stubbingPolicy = stubbingPolicy + self.file = file + self.line = line + } + + var matcher: Matcher = Matcher.default + var stubbingPolicy: StubbingPolicy = .wrap + var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + + private var queue = DispatchQueue(label: "com.swiftymocky.invocations", qos: .userInteractive) + private var invocations: [MethodType] = [] + private var methodReturnValues: [Given] = [] + private var methodPerformValues: [Perform] = [] + private var file: StaticString? + private var line: UInt? + + public typealias PropertyStub = Given + public typealias MethodStub = Given + public typealias SubscriptStub = Given + + /// Convenience method - call setupMock() to extend debug information when failure occurs + public func setupMock(file: StaticString = #file, line: UInt = #line) { + self.file = file + self.line = line + } + + /// Clear mock internals. You can specify what to reset (invocations aka verify, givens or performs) or leave it empty to clear all mock internals + public func resetMock(_ scopes: MockScope...) { + let scopes: [MockScope] = scopes.isEmpty ? [.invocation, .given, .perform] : scopes + if scopes.contains(.invocation) { invocations = [] } + if scopes.contains(.given) { methodReturnValues = [] } + if scopes.contains(.perform) { methodPerformValues = [] } + } + + + + + + open func profileEditClicked() { + addInvocation(.m_profileEditClicked) + let perform = methodPerformValue(.m_profileEditClicked) as? () -> Void + perform?() + } + + open func 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 + perform?() + } + + open func profileDeleteAccountClicked() { + addInvocation(.m_profileDeleteAccountClicked) + let perform = methodPerformValue(.m_profileDeleteAccountClicked) as? () -> Void + perform?() + } + + open func profileVideoSettingsClicked() { + addInvocation(.m_profileVideoSettingsClicked) + let perform = methodPerformValue(.m_profileVideoSettingsClicked) as? () -> Void + perform?() + } + + open func privacyPolicyClicked() { + addInvocation(.m_privacyPolicyClicked) + let perform = methodPerformValue(.m_privacyPolicyClicked) as? () -> Void + perform?() + } + + open func cookiePolicyClicked() { + addInvocation(.m_cookiePolicyClicked) + let perform = methodPerformValue(.m_cookiePolicyClicked) as? () -> Void + perform?() + } + + open func emailSupportClicked() { + addInvocation(.m_emailSupportClicked) + let perform = methodPerformValue(.m_emailSupportClicked) as? () -> Void + perform?() + } + + open func 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 profileTrackEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_profileTrackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_profileTrackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void + perform?(`event`, `biValue`) + } + + open func profileScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { + addInvocation(.m_profileScreenEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) + let perform = methodPerformValue(.m_profileScreenEvent__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_profileTrackEvent__eventbiValue_biValue(Parameter, Parameter) + case m_profileScreenEvent__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 -open class OfflineSyncInteractorProtocolMock: OfflineSyncInteractorProtocol, 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 - } + 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) - var matcher: Matcher = Matcher.default - var stubbingPolicy: StubbingPolicy = .wrap - var sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst + case (.m_profileEditDoneClicked, .m_profileEditDoneClicked): return .match - 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? + case (.m_profileDeleteAccountClicked, .m_profileDeleteAccountClicked): return .match - public typealias PropertyStub = Given - public typealias MethodStub = Given - public typealias SubscriptStub = Given + case (.m_profileVideoSettingsClicked, .m_profileVideoSettingsClicked): return .match - /// 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 - } + case (.m_privacyPolicyClicked, .m_privacyPolicyClicked): return .match - /// 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 = [] } - } + case (.m_cookiePolicyClicked, .m_cookiePolicyClicked): return .match + + 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) - open func submitOfflineProgress(courseID: String, blockID: String, data: String) throws -> Bool { - addInvocation(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) - let perform = methodPerformValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))) as? (String, String, String) -> Void - perform?(`courseID`, `blockID`, `data`) - var __value: Bool - do { - __value = try methodReturnValue(.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter.value(`courseID`), Parameter.value(`blockID`), Parameter.value(`data`))).casted() - } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") - Failure("Stub return value not specified for submitOfflineProgress(courseID: String, blockID: String, data: String). Use given") - } catch { - throw error - } - return __value - } + 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 - fileprivate enum MethodType { - case m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(Parameter, Parameter, Parameter) + 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) - static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { - switch (lhs, rhs) { - case (.m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let lhsCourseid, let lhsBlockid, let lhsData), .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(let rhsCourseid, let rhsBlockid, let rhsData)): + case (.m_profileTrackEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_profileTrackEvent__eventbiValue_biValue(let rhsEvent, let rhsBivalue)): 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: lhsData, rhs: rhsData, with: matcher), lhsData, rhsData, "data")) + 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) + + case (.m_profileScreenEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_profileScreenEvent__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_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(p0, p1, p2): return p0.intValue + p1.intValue + p2.intValue + 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_profileTrackEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue + case let .m_profileScreenEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue } } func assertionName() -> String { switch self { - case .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data: return ".submitOfflineProgress(courseID:blockID:data:)" + 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_profileTrackEvent__eventbiValue_biValue: return ".profileTrackEvent(_:biValue:)" + case .m_profileScreenEvent__eventbiValue_biValue: return ".profileScreenEvent(_:biValue:)" } } } @@ -3291,33 +4565,84 @@ open class OfflineSyncInteractorProtocolMock: OfflineSyncInteractorProtocol, Moc } - public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willReturn: Bool...) -> MethodStub { - return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willReturn.map({ StubProduct.return($0 as Any) })) - } - public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willThrow: Error...) -> MethodStub { - return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) - } - public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { - let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (Bool).self) - willProduce(stubber) - return given - } } public struct Verify { fileprivate var method: MethodType - public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter) -> Verify { return Verify(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`))} + 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 profileTrackEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_profileTrackEvent__eventbiValue_biValue(`event`, `biValue`))} + public static func profileScreenEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_profileScreenEvent__eventbiValue_biValue(`event`, `biValue`))} } public struct Perform { fileprivate var method: MethodType var performs: Any - public static func submitOfflineProgress(courseID: Parameter, blockID: Parameter, data: Parameter, perform: @escaping (String, String, String) -> Void) -> Perform { - return Perform(method: .m_submitOfflineProgress__courseID_courseIDblockID_blockIDdata_data(`courseID`, `blockID`, `data`), performs: perform) + 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) + } + public static func profileDeleteAccountClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_profileDeleteAccountClicked, performs: perform) + } + public static func profileVideoSettingsClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_profileVideoSettingsClicked, performs: perform) + } + public static func privacyPolicyClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_privacyPolicyClicked, performs: perform) + } + public static func cookiePolicyClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_cookiePolicyClicked, performs: perform) + } + public static func emailSupportClicked(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_emailSupportClicked, performs: perform) + } + public static func 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 profileTrackEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_profileTrackEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + } + public static func profileScreenEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { + return Perform(method: .m_profileScreenEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) } } @@ -3394,9 +4719,9 @@ open class OfflineSyncInteractorProtocolMock: OfflineSyncInteractorProtocol, Moc } } -// MARK: - ProfileAnalytics +// MARK: - ProfileInteractorProtocol -open class ProfileAnalyticsMock: ProfileAnalytics, Mock { +open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() self.sequencingPolicy = sequencingPolicy @@ -3436,184 +4761,270 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { - - - open func profileEditClicked() { - addInvocation(.m_profileEditClicked) - let perform = methodPerformValue(.m_profileEditClicked) as? () -> Void - 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 - perform?() - } - - open func profileDeleteAccountClicked() { - addInvocation(.m_profileDeleteAccountClicked) - let perform = methodPerformValue(.m_profileDeleteAccountClicked) as? () -> Void - perform?() + + + open func getUserProfile(username: String) throws -> UserProfile { + addInvocation(.m_getUserProfile__username_username(Parameter.value(`username`))) + let perform = methodPerformValue(.m_getUserProfile__username_username(Parameter.value(`username`))) as? (String) -> Void + perform?(`username`) + var __value: UserProfile + do { + __value = try methodReturnValue(.m_getUserProfile__username_username(Parameter.value(`username`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for getUserProfile(username: String). Use given") + Failure("Stub return value not specified for getUserProfile(username: String). Use given") + } catch { + throw error + } + return __value } - open func profileVideoSettingsClicked() { - addInvocation(.m_profileVideoSettingsClicked) - let perform = methodPerformValue(.m_profileVideoSettingsClicked) as? () -> Void + open func getMyProfile() throws -> UserProfile { + addInvocation(.m_getMyProfile) + let perform = methodPerformValue(.m_getMyProfile) as? () -> Void perform?() + var __value: UserProfile + do { + __value = try methodReturnValue(.m_getMyProfile).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for getMyProfile(). Use given") + Failure("Stub return value not specified for getMyProfile(). Use given") + } catch { + throw error + } + return __value } - open func privacyPolicyClicked() { - addInvocation(.m_privacyPolicyClicked) - let perform = methodPerformValue(.m_privacyPolicyClicked) as? () -> Void + open func getMyProfileOffline() -> UserProfile? { + addInvocation(.m_getMyProfileOffline) + let perform = methodPerformValue(.m_getMyProfileOffline) as? () -> Void perform?() + var __value: UserProfile? = nil + do { + __value = try methodReturnValue(.m_getMyProfileOffline).casted() + } catch { + // do nothing + } + return __value } - open func cookiePolicyClicked() { - addInvocation(.m_cookiePolicyClicked) - let perform = methodPerformValue(.m_cookiePolicyClicked) as? () -> Void + open func logOut() throws { + addInvocation(.m_logOut) + let perform = methodPerformValue(.m_logOut) as? () -> Void perform?() + do { + _ = try methodReturnValue(.m_logOut).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } } - open func emailSupportClicked() { - addInvocation(.m_emailSupportClicked) - let perform = methodPerformValue(.m_emailSupportClicked) as? () -> Void + open func getSpokenLanguages() -> [PickerFields.Option] { + addInvocation(.m_getSpokenLanguages) + let perform = methodPerformValue(.m_getSpokenLanguages) as? () -> Void perform?() + var __value: [PickerFields.Option] + do { + __value = try methodReturnValue(.m_getSpokenLanguages).casted() + } catch { + onFatalFailure("Stub return value not specified for getSpokenLanguages(). Use given") + Failure("Stub return value not specified for getSpokenLanguages(). Use given") + } + return __value } - open func faqClicked() { - addInvocation(.m_faqClicked) - let perform = methodPerformValue(.m_faqClicked) as? () -> Void + open func getCountries() -> [PickerFields.Option] { + addInvocation(.m_getCountries) + let perform = methodPerformValue(.m_getCountries) as? () -> Void perform?() + var __value: [PickerFields.Option] + do { + __value = try methodReturnValue(.m_getCountries).casted() + } catch { + onFatalFailure("Stub return value not specified for getCountries(). Use given") + Failure("Stub return value not specified for getCountries(). Use given") + } + return __value } - open func tosClicked() { - addInvocation(.m_tosClicked) - let perform = methodPerformValue(.m_tosClicked) as? () -> Void - perform?() + open func uploadProfilePicture(pictureData: Data) throws { + addInvocation(.m_uploadProfilePicture__pictureData_pictureData(Parameter.value(`pictureData`))) + let perform = methodPerformValue(.m_uploadProfilePicture__pictureData_pictureData(Parameter.value(`pictureData`))) as? (Data) -> Void + perform?(`pictureData`) + do { + _ = try methodReturnValue(.m_uploadProfilePicture__pictureData_pictureData(Parameter.value(`pictureData`))).casted() as Void + } catch MockError.notStubed { + // do nothing + } catch { + throw error + } } - open func dataSellClicked() { - addInvocation(.m_dataSellClicked) - let perform = methodPerformValue(.m_dataSellClicked) as? () -> Void + open func deleteProfilePicture() throws -> Bool { + addInvocation(.m_deleteProfilePicture) + let perform = methodPerformValue(.m_deleteProfilePicture) as? () -> Void perform?() + var __value: Bool + do { + __value = try methodReturnValue(.m_deleteProfilePicture).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for deleteProfilePicture(). Use given") + Failure("Stub return value not specified for deleteProfilePicture(). Use given") + } catch { + throw error + } + return __value } - 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 updateUserProfile(parameters: [String: Any]) throws -> UserProfile { + addInvocation(.m_updateUserProfile__parameters_parameters(Parameter<[String: Any]>.value(`parameters`))) + let perform = methodPerformValue(.m_updateUserProfile__parameters_parameters(Parameter<[String: Any]>.value(`parameters`))) as? ([String: Any]) -> Void + perform?(`parameters`) + var __value: UserProfile + do { + __value = try methodReturnValue(.m_updateUserProfile__parameters_parameters(Parameter<[String: Any]>.value(`parameters`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for updateUserProfile(parameters: [String: Any]). Use given") + Failure("Stub return value not specified for updateUserProfile(parameters: [String: Any]). Use given") + } catch { + throw error + } + return __value } - 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 deleteAccount(password: String) throws -> Bool { + addInvocation(.m_deleteAccount__password_password(Parameter.value(`password`))) + let perform = methodPerformValue(.m_deleteAccount__password_password(Parameter.value(`password`))) as? (String) -> Void + perform?(`password`) + var __value: Bool + do { + __value = try methodReturnValue(.m_deleteAccount__password_password(Parameter.value(`password`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for deleteAccount(password: String). Use given") + Failure("Stub return value not specified for deleteAccount(password: String). Use given") + } catch { + throw error + } + return __value } - open func profileUserDeleteAccountClicked() { - addInvocation(.m_profileUserDeleteAccountClicked) - let perform = methodPerformValue(.m_profileUserDeleteAccountClicked) as? () -> Void + open func getSettings() -> UserSettings { + addInvocation(.m_getSettings) + let perform = methodPerformValue(.m_getSettings) as? () -> Void perform?() + var __value: UserSettings + do { + __value = try methodReturnValue(.m_getSettings).casted() + } catch { + onFatalFailure("Stub return value not specified for getSettings(). Use given") + Failure("Stub return value not specified for getSettings(). Use given") + } + return __value } - 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 saveSettings(_ settings: UserSettings) { + addInvocation(.m_saveSettings__settings(Parameter.value(`settings`))) + let perform = methodPerformValue(.m_saveSettings__settings(Parameter.value(`settings`))) as? (UserSettings) -> Void + perform?(`settings`) } - open func profileTrackEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { - addInvocation(.m_profileTrackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) - let perform = methodPerformValue(.m_profileTrackEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void - perform?(`event`, `biValue`) + open func enrollmentsStatus() throws -> [CourseForSync] { + addInvocation(.m_enrollmentsStatus) + let perform = methodPerformValue(.m_enrollmentsStatus) as? () -> Void + perform?() + var __value: [CourseForSync] + do { + __value = try methodReturnValue(.m_enrollmentsStatus).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for enrollmentsStatus(). Use given") + Failure("Stub return value not specified for enrollmentsStatus(). Use given") + } catch { + throw error + } + return __value } - open func profileScreenEvent(_ event: AnalyticsEvent, biValue: EventBIValue) { - addInvocation(.m_profileScreenEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) - let perform = methodPerformValue(.m_profileScreenEvent__eventbiValue_biValue(Parameter.value(`event`), Parameter.value(`biValue`))) as? (AnalyticsEvent, EventBIValue) -> Void - perform?(`event`, `biValue`) + open func getCourseDates(courseID: String) throws -> CourseDates { + addInvocation(.m_getCourseDates__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_getCourseDates__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + var __value: CourseDates + do { + __value = try methodReturnValue(.m_getCourseDates__courseID_courseID(Parameter.value(`courseID`))).casted() + } catch MockError.notStubed { + onFatalFailure("Stub return value not specified for getCourseDates(courseID: String). Use given") + Failure("Stub return value not specified for getCourseDates(courseID: String). Use given") + } catch { + throw error + } + return __value } fileprivate enum MethodType { - case m_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_profileTrackEvent__eventbiValue_biValue(Parameter, Parameter) - case m_profileScreenEvent__eventbiValue_biValue(Parameter, Parameter) + case m_getUserProfile__username_username(Parameter) + case m_getMyProfile + case m_getMyProfileOffline + case m_logOut + case m_getSpokenLanguages + case m_getCountries + case m_uploadProfilePicture__pictureData_pictureData(Parameter) + case m_deleteProfilePicture + case m_updateUserProfile__parameters_parameters(Parameter<[String: Any]>) + case m_deleteAccount__password_password(Parameter) + case m_getSettings + case m_saveSettings__settings(Parameter) + case m_enrollmentsStatus + case m_getCourseDates__courseID_courseID(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)): + case (.m_getUserProfile__username_username(let lhsUsername), .m_getUserProfile__username_username(let rhsUsername)): 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 + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUsername, rhs: rhsUsername, with: matcher), lhsUsername, rhsUsername, "username")) + return Matcher.ComparisonResult(results) - case (.m_profileVideoSettingsClicked, .m_profileVideoSettingsClicked): return .match + case (.m_getMyProfile, .m_getMyProfile): return .match - case (.m_privacyPolicyClicked, .m_privacyPolicyClicked): return .match + case (.m_getMyProfileOffline, .m_getMyProfileOffline): return .match - case (.m_cookiePolicyClicked, .m_cookiePolicyClicked): return .match + case (.m_logOut, .m_logOut): return .match - case (.m_emailSupportClicked, .m_emailSupportClicked): return .match + case (.m_getSpokenLanguages, .m_getSpokenLanguages): return .match - case (.m_faqClicked, .m_faqClicked): return .match + case (.m_getCountries, .m_getCountries): return .match - case (.m_tosClicked, .m_tosClicked): return .match + case (.m_uploadProfilePicture__pictureData_pictureData(let lhsPicturedata), .m_uploadProfilePicture__pictureData_pictureData(let rhsPicturedata)): + var results: [Matcher.ParameterComparisonResult] = [] + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsPicturedata, rhs: rhsPicturedata, with: matcher), lhsPicturedata, rhsPicturedata, "pictureData")) + return Matcher.ComparisonResult(results) - case (.m_dataSellClicked, .m_dataSellClicked): return .match + case (.m_deleteProfilePicture, .m_deleteProfilePicture): return .match - case (.m_userLogout__force_force(let lhsForce), .m_userLogout__force_force(let rhsForce)): + case (.m_updateUserProfile__parameters_parameters(let lhsParameters), .m_updateUserProfile__parameters_parameters(let rhsParameters)): 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: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) return Matcher.ComparisonResult(results) - case (.m_profileWifiToggle__action_action(let lhsAction), .m_profileWifiToggle__action_action(let rhsAction)): + case (.m_deleteAccount__password_password(let lhsPassword), .m_deleteAccount__password_password(let rhsPassword)): var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsAction, rhs: rhsAction, with: matcher), lhsAction, rhsAction, "action")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsPassword, rhs: rhsPassword, with: matcher), lhsPassword, rhsPassword, "password")) return Matcher.ComparisonResult(results) - case (.m_profileUserDeleteAccountClicked, .m_profileUserDeleteAccountClicked): return .match + case (.m_getSettings, .m_getSettings): return .match - case (.m_profileDeleteAccountSuccess__success_success(let lhsSuccess), .m_profileDeleteAccountSuccess__success_success(let rhsSuccess)): + case (.m_saveSettings__settings(let lhsSettings), .m_saveSettings__settings(let rhsSettings)): var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSuccess, rhs: rhsSuccess, with: matcher), lhsSuccess, rhsSuccess, "success")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSettings, rhs: rhsSettings, with: matcher), lhsSettings, rhsSettings, "_ settings")) return Matcher.ComparisonResult(results) - case (.m_profileTrackEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_profileTrackEvent__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) + case (.m_enrollmentsStatus, .m_enrollmentsStatus): return .match - case (.m_profileScreenEvent__eventbiValue_biValue(let lhsEvent, let lhsBivalue), .m_profileScreenEvent__eventbiValue_biValue(let rhsEvent, let rhsBivalue)): + case (.m_getCourseDates__courseID_courseID(let lhsCourseid), .m_getCourseDates__courseID_courseID(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) default: return .none } @@ -3621,135 +5032,268 @@ 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_profileTrackEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue - case let .m_profileScreenEvent__eventbiValue_biValue(p0, p1): return p0.intValue + p1.intValue + case let .m_getUserProfile__username_username(p0): return p0.intValue + case .m_getMyProfile: return 0 + case .m_getMyProfileOffline: return 0 + case .m_logOut: return 0 + case .m_getSpokenLanguages: return 0 + case .m_getCountries: return 0 + case let .m_uploadProfilePicture__pictureData_pictureData(p0): return p0.intValue + case .m_deleteProfilePicture: return 0 + case let .m_updateUserProfile__parameters_parameters(p0): return p0.intValue + case let .m_deleteAccount__password_password(p0): return p0.intValue + case .m_getSettings: return 0 + case let .m_saveSettings__settings(p0): return p0.intValue + case .m_enrollmentsStatus: return 0 + case let .m_getCourseDates__courseID_courseID(p0): return p0.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_profileTrackEvent__eventbiValue_biValue: return ".profileTrackEvent(_:biValue:)" - case .m_profileScreenEvent__eventbiValue_biValue: return ".profileScreenEvent(_:biValue:)" + case .m_getUserProfile__username_username: return ".getUserProfile(username:)" + case .m_getMyProfile: return ".getMyProfile()" + case .m_getMyProfileOffline: return ".getMyProfileOffline()" + case .m_logOut: return ".logOut()" + case .m_getSpokenLanguages: return ".getSpokenLanguages()" + case .m_getCountries: return ".getCountries()" + case .m_uploadProfilePicture__pictureData_pictureData: return ".uploadProfilePicture(pictureData:)" + case .m_deleteProfilePicture: return ".deleteProfilePicture()" + case .m_updateUserProfile__parameters_parameters: return ".updateUserProfile(parameters:)" + case .m_deleteAccount__password_password: return ".deleteAccount(password:)" + case .m_getSettings: return ".getSettings()" + case .m_saveSettings__settings: return ".saveSettings(_:)" + case .m_enrollmentsStatus: return ".enrollmentsStatus()" + case .m_getCourseDates__courseID_courseID: return ".getCourseDates(courseID:)" } } - } - - open class Given: StubbedMethod { - fileprivate var method: MethodType - - private init(method: MethodType, products: [StubProduct]) { - self.method = method - super.init(products) + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + + public static func getUserProfile(username: Parameter, willReturn: UserProfile...) -> MethodStub { + return Given(method: .m_getUserProfile__username_username(`username`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getMyProfile(willReturn: UserProfile...) -> MethodStub { + return Given(method: .m_getMyProfile, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getMyProfileOffline(willReturn: UserProfile?...) -> MethodStub { + return Given(method: .m_getMyProfileOffline, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getSpokenLanguages(willReturn: [PickerFields.Option]...) -> MethodStub { + return Given(method: .m_getSpokenLanguages, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getCountries(willReturn: [PickerFields.Option]...) -> MethodStub { + return Given(method: .m_getCountries, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func deleteProfilePicture(willReturn: Bool...) -> MethodStub { + return Given(method: .m_deleteProfilePicture, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func updateUserProfile(parameters: Parameter<[String: Any]>, willReturn: UserProfile...) -> MethodStub { + return Given(method: .m_updateUserProfile__parameters_parameters(`parameters`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func deleteAccount(password: Parameter, willReturn: Bool...) -> MethodStub { + return Given(method: .m_deleteAccount__password_password(`password`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getSettings(willReturn: UserSettings...) -> MethodStub { + return Given(method: .m_getSettings, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func enrollmentsStatus(willReturn: [CourseForSync]...) -> MethodStub { + return Given(method: .m_enrollmentsStatus, products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getCourseDates(courseID: Parameter, willReturn: CourseDates...) -> MethodStub { + return Given(method: .m_getCourseDates__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getMyProfileOffline(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [UserProfile?] = [] + let given: Given = { return Given(method: .m_getMyProfileOffline, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (UserProfile?).self) + willProduce(stubber) + return given + } + public static func getSpokenLanguages(willProduce: (Stubber<[PickerFields.Option]>) -> Void) -> MethodStub { + let willReturn: [[PickerFields.Option]] = [] + let given: Given = { return Given(method: .m_getSpokenLanguages, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([PickerFields.Option]).self) + willProduce(stubber) + return given + } + public static func getCountries(willProduce: (Stubber<[PickerFields.Option]>) -> Void) -> MethodStub { + let willReturn: [[PickerFields.Option]] = [] + let given: Given = { return Given(method: .m_getCountries, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([PickerFields.Option]).self) + willProduce(stubber) + return given + } + public static func getSettings(willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [UserSettings] = [] + let given: Given = { return Given(method: .m_getSettings, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (UserSettings).self) + willProduce(stubber) + return given + } + public static func getUserProfile(username: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_getUserProfile__username_username(`username`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getUserProfile(username: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_getUserProfile__username_username(`username`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (UserProfile).self) + willProduce(stubber) + return given + } + public static func getMyProfile(willThrow: Error...) -> MethodStub { + return Given(method: .m_getMyProfile, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getMyProfile(willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_getMyProfile, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (UserProfile).self) + willProduce(stubber) + return given + } + public static func logOut(willThrow: Error...) -> MethodStub { + return Given(method: .m_logOut, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func logOut(willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_logOut, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func uploadProfilePicture(pictureData: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_uploadProfilePicture__pictureData_pictureData(`pictureData`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func uploadProfilePicture(pictureData: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_uploadProfilePicture__pictureData_pictureData(`pictureData`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Void).self) + willProduce(stubber) + return given + } + public static func deleteProfilePicture(willThrow: Error...) -> MethodStub { + return Given(method: .m_deleteProfilePicture, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func deleteProfilePicture(willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_deleteProfilePicture, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Bool).self) + willProduce(stubber) + return given + } + public static func updateUserProfile(parameters: Parameter<[String: Any]>, willThrow: Error...) -> MethodStub { + return Given(method: .m_updateUserProfile__parameters_parameters(`parameters`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func updateUserProfile(parameters: Parameter<[String: Any]>, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_updateUserProfile__parameters_parameters(`parameters`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (UserProfile).self) + willProduce(stubber) + return given + } + public static func deleteAccount(password: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_deleteAccount__password_password(`password`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func deleteAccount(password: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_deleteAccount__password_password(`password`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (Bool).self) + willProduce(stubber) + return given + } + public static func enrollmentsStatus(willThrow: Error...) -> MethodStub { + return Given(method: .m_enrollmentsStatus, products: willThrow.map({ StubProduct.throw($0) })) + } + public static func enrollmentsStatus(willProduce: (StubberThrows<[CourseForSync]>) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_enrollmentsStatus, products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: ([CourseForSync]).self) + willProduce(stubber) + return given + } + public static func getCourseDates(courseID: Parameter, willThrow: Error...) -> MethodStub { + return Given(method: .m_getCourseDates__courseID_courseID(`courseID`), products: willThrow.map({ StubProduct.throw($0) })) + } + public static func getCourseDates(courseID: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { + let willThrow: [Error] = [] + let given: Given = { return Given(method: .m_getCourseDates__courseID_courseID(`courseID`), products: willThrow.map({ StubProduct.throw($0) })) }() + let stubber = given.stubThrows(for: (CourseDates).self) + willProduce(stubber) + return given } - - } public struct Verify { 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 profileTrackEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_profileTrackEvent__eventbiValue_biValue(`event`, `biValue`))} - public static func profileScreenEvent(_ event: Parameter, biValue: Parameter) -> Verify { return Verify(method: .m_profileScreenEvent__eventbiValue_biValue(`event`, `biValue`))} + public static func getUserProfile(username: Parameter) -> Verify { return Verify(method: .m_getUserProfile__username_username(`username`))} + public static func getMyProfile() -> Verify { return Verify(method: .m_getMyProfile)} + public static func getMyProfileOffline() -> Verify { return Verify(method: .m_getMyProfileOffline)} + public static func logOut() -> Verify { return Verify(method: .m_logOut)} + public static func getSpokenLanguages() -> Verify { return Verify(method: .m_getSpokenLanguages)} + public static func getCountries() -> Verify { return Verify(method: .m_getCountries)} + public static func uploadProfilePicture(pictureData: Parameter) -> Verify { return Verify(method: .m_uploadProfilePicture__pictureData_pictureData(`pictureData`))} + public static func deleteProfilePicture() -> Verify { return Verify(method: .m_deleteProfilePicture)} + public static func updateUserProfile(parameters: Parameter<[String: Any]>) -> Verify { return Verify(method: .m_updateUserProfile__parameters_parameters(`parameters`))} + public static func deleteAccount(password: Parameter) -> Verify { return Verify(method: .m_deleteAccount__password_password(`password`))} + public static func getSettings() -> Verify { return Verify(method: .m_getSettings)} + public static func saveSettings(_ settings: Parameter) -> Verify { return Verify(method: .m_saveSettings__settings(`settings`))} + public static func enrollmentsStatus() -> Verify { return Verify(method: .m_enrollmentsStatus)} + public static func getCourseDates(courseID: Parameter) -> Verify { return Verify(method: .m_getCourseDates__courseID_courseID(`courseID`))} } public struct Perform { fileprivate var method: MethodType var performs: Any - public static func profileEditClicked(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_profileEditClicked, performs: perform) - } - public static func 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) - } - public static func profileDeleteAccountClicked(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_profileDeleteAccountClicked, performs: perform) + public static func getUserProfile(username: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getUserProfile__username_username(`username`), performs: perform) } - public static func profileVideoSettingsClicked(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_profileVideoSettingsClicked, performs: perform) + public static func getMyProfile(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getMyProfile, performs: perform) } - public static func privacyPolicyClicked(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_privacyPolicyClicked, performs: perform) + public static func getMyProfileOffline(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getMyProfileOffline, performs: perform) } - public static func cookiePolicyClicked(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_cookiePolicyClicked, performs: perform) + public static func logOut(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_logOut, performs: perform) } - public static func emailSupportClicked(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_emailSupportClicked, performs: perform) + public static func getSpokenLanguages(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getSpokenLanguages, performs: perform) } - public static func faqClicked(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_faqClicked, performs: perform) + public static func getCountries(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getCountries, performs: perform) } - public static func tosClicked(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_tosClicked, performs: perform) + public static func uploadProfilePicture(pictureData: Parameter, perform: @escaping (Data) -> Void) -> Perform { + return Perform(method: .m_uploadProfilePicture__pictureData_pictureData(`pictureData`), performs: perform) } - public static func dataSellClicked(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_dataSellClicked, performs: perform) + public static func deleteProfilePicture(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteProfilePicture, 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 updateUserProfile(parameters: Parameter<[String: Any]>, perform: @escaping ([String: Any]) -> Void) -> Perform { + return Perform(method: .m_updateUserProfile__parameters_parameters(`parameters`), 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 deleteAccount(password: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_deleteAccount__password_password(`password`), performs: perform) } - public static func profileUserDeleteAccountClicked(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_profileUserDeleteAccountClicked, performs: perform) + public static func getSettings(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getSettings, 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 saveSettings(_ settings: Parameter, perform: @escaping (UserSettings) -> Void) -> Perform { + return Perform(method: .m_saveSettings__settings(`settings`), performs: perform) } - public static func profileTrackEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { - return Perform(method: .m_profileTrackEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + public static func enrollmentsStatus(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_enrollmentsStatus, performs: perform) } - public static func profileScreenEvent(_ event: Parameter, biValue: Parameter, perform: @escaping (AnalyticsEvent, EventBIValue) -> Void) -> Perform { - return Perform(method: .m_profileScreenEvent__eventbiValue_biValue(`event`, `biValue`), performs: perform) + public static func getCourseDates(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getCourseDates__courseID_courseID(`courseID`), performs: perform) } } @@ -3826,9 +5370,9 @@ open class ProfileAnalyticsMock: ProfileAnalytics, Mock { } } -// MARK: - ProfileInteractorProtocol +// MARK: - ProfilePersistenceProtocol -open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { +open class ProfilePersistenceProtocolMock: ProfilePersistenceProtocol, Mock { public init(sequencing sequencingPolicy: SequencingPolicy = .lastWrittenResolvedFirst, stubbing stubbingPolicy: StubbingPolicy = .wrap, file: StaticString = #file, line: UInt = #line) { SwiftyMockyTestObserver.setup() self.sequencingPolicy = sequencingPolicy @@ -3870,268 +5414,131 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { - open func getUserProfile(username: String) throws -> UserProfile { - addInvocation(.m_getUserProfile__username_username(Parameter.value(`username`))) - let perform = methodPerformValue(.m_getUserProfile__username_username(Parameter.value(`username`))) as? (String) -> Void - perform?(`username`) - var __value: UserProfile - do { - __value = try methodReturnValue(.m_getUserProfile__username_username(Parameter.value(`username`))).casted() - } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for getUserProfile(username: String). Use given") - Failure("Stub return value not specified for getUserProfile(username: String). Use given") - } catch { - throw error - } - return __value - } - - open func getMyProfile() throws -> UserProfile { - addInvocation(.m_getMyProfile) - let perform = methodPerformValue(.m_getMyProfile) as? () -> Void - perform?() - var __value: UserProfile - do { - __value = try methodReturnValue(.m_getMyProfile).casted() - } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for getMyProfile(). Use given") - Failure("Stub return value not specified for getMyProfile(). Use given") - } catch { - throw error - } - return __value - } - - open func getMyProfileOffline() -> UserProfile? { - addInvocation(.m_getMyProfileOffline) - let perform = methodPerformValue(.m_getMyProfileOffline) as? () -> Void - perform?() - var __value: UserProfile? = nil - do { - __value = try methodReturnValue(.m_getMyProfileOffline).casted() - } catch { - // do nothing - } - return __value - } - - open func logOut() throws { - addInvocation(.m_logOut) - let perform = methodPerformValue(.m_logOut) as? () -> Void - perform?() - do { - _ = try methodReturnValue(.m_logOut).casted() as Void - } catch MockError.notStubed { - // do nothing - } catch { - throw error - } - } - - open func getSpokenLanguages() -> [PickerFields.Option] { - addInvocation(.m_getSpokenLanguages) - let perform = methodPerformValue(.m_getSpokenLanguages) as? () -> Void - perform?() - var __value: [PickerFields.Option] - do { - __value = try methodReturnValue(.m_getSpokenLanguages).casted() - } catch { - onFatalFailure("Stub return value not specified for getSpokenLanguages(). Use given") - Failure("Stub return value not specified for getSpokenLanguages(). Use given") - } - return __value - } - - open func getCountries() -> [PickerFields.Option] { - addInvocation(.m_getCountries) - let perform = methodPerformValue(.m_getCountries) as? () -> Void - perform?() - var __value: [PickerFields.Option] - do { - __value = try methodReturnValue(.m_getCountries).casted() - } catch { - onFatalFailure("Stub return value not specified for getCountries(). Use given") - Failure("Stub return value not specified for getCountries(). Use given") - } - return __value - } - - open func uploadProfilePicture(pictureData: Data) throws { - addInvocation(.m_uploadProfilePicture__pictureData_pictureData(Parameter.value(`pictureData`))) - let perform = methodPerformValue(.m_uploadProfilePicture__pictureData_pictureData(Parameter.value(`pictureData`))) as? (Data) -> Void - perform?(`pictureData`) - do { - _ = try methodReturnValue(.m_uploadProfilePicture__pictureData_pictureData(Parameter.value(`pictureData`))).casted() as Void - } catch MockError.notStubed { - // do nothing - } catch { - throw error - } - } - - open func deleteProfilePicture() throws -> Bool { - addInvocation(.m_deleteProfilePicture) - let perform = methodPerformValue(.m_deleteProfilePicture) as? () -> Void - perform?() - var __value: Bool - do { - __value = try methodReturnValue(.m_deleteProfilePicture).casted() - } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for deleteProfilePicture(). Use given") - Failure("Stub return value not specified for deleteProfilePicture(). Use given") - } catch { - throw error - } - return __value - } - - open func updateUserProfile(parameters: [String: Any]) throws -> UserProfile { - addInvocation(.m_updateUserProfile__parameters_parameters(Parameter<[String: Any]>.value(`parameters`))) - let perform = methodPerformValue(.m_updateUserProfile__parameters_parameters(Parameter<[String: Any]>.value(`parameters`))) as? ([String: Any]) -> Void - perform?(`parameters`) - var __value: UserProfile - do { - __value = try methodReturnValue(.m_updateUserProfile__parameters_parameters(Parameter<[String: Any]>.value(`parameters`))).casted() - } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for updateUserProfile(parameters: [String: Any]). Use given") - Failure("Stub return value not specified for updateUserProfile(parameters: [String: Any]). Use given") - } catch { - throw error - } - return __value - } - - open func deleteAccount(password: String) throws -> Bool { - addInvocation(.m_deleteAccount__password_password(Parameter.value(`password`))) - let perform = methodPerformValue(.m_deleteAccount__password_password(Parameter.value(`password`))) as? (String) -> Void - perform?(`password`) - var __value: Bool + open func getCourseState(courseID: String) -> CourseCalendarState? { + addInvocation(.m_getCourseState__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_getCourseState__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + var __value: CourseCalendarState? = nil do { - __value = try methodReturnValue(.m_deleteAccount__password_password(Parameter.value(`password`))).casted() - } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for deleteAccount(password: String). Use given") - Failure("Stub return value not specified for deleteAccount(password: String). Use given") + __value = try methodReturnValue(.m_getCourseState__courseID_courseID(Parameter.value(`courseID`))).casted() } catch { - throw error + // do nothing } return __value } - open func getSettings() -> UserSettings { - addInvocation(.m_getSettings) - let perform = methodPerformValue(.m_getSettings) as? () -> Void + open func getAllCourseStates() -> [CourseCalendarState] { + addInvocation(.m_getAllCourseStates) + let perform = methodPerformValue(.m_getAllCourseStates) as? () -> Void perform?() - var __value: UserSettings + var __value: [CourseCalendarState] do { - __value = try methodReturnValue(.m_getSettings).casted() + __value = try methodReturnValue(.m_getAllCourseStates).casted() } catch { - onFatalFailure("Stub return value not specified for getSettings(). Use given") - Failure("Stub return value not specified for getSettings(). Use given") + onFatalFailure("Stub return value not specified for getAllCourseStates(). Use given") + Failure("Stub return value not specified for getAllCourseStates(). Use given") } return __value } - open func saveSettings(_ settings: UserSettings) { - addInvocation(.m_saveSettings__settings(Parameter.value(`settings`))) - let perform = methodPerformValue(.m_saveSettings__settings(Parameter.value(`settings`))) as? (UserSettings) -> Void - perform?(`settings`) + open func saveCourseState(state: CourseCalendarState) { + addInvocation(.m_saveCourseState__state_state(Parameter.value(`state`))) + let perform = methodPerformValue(.m_saveCourseState__state_state(Parameter.value(`state`))) as? (CourseCalendarState) -> Void + perform?(`state`) } - open func enrollmentsStatus() throws -> [CourseForSync] { - addInvocation(.m_enrollmentsStatus) - let perform = methodPerformValue(.m_enrollmentsStatus) as? () -> Void + open func removeCourseState(courseID: String) { + addInvocation(.m_removeCourseState__courseID_courseID(Parameter.value(`courseID`))) + let perform = methodPerformValue(.m_removeCourseState__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void + perform?(`courseID`) + } + + open func deleteAllCourseStatesAndEvents() { + addInvocation(.m_deleteAllCourseStatesAndEvents) + let perform = methodPerformValue(.m_deleteAllCourseStatesAndEvents) as? () -> Void perform?() - var __value: [CourseForSync] - do { - __value = try methodReturnValue(.m_enrollmentsStatus).casted() - } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for enrollmentsStatus(). Use given") - Failure("Stub return value not specified for enrollmentsStatus(). Use given") - } catch { - throw error - } - return __value } - open func getCourseDates(courseID: String) throws -> CourseDates { - addInvocation(.m_getCourseDates__courseID_courseID(Parameter.value(`courseID`))) - let perform = methodPerformValue(.m_getCourseDates__courseID_courseID(Parameter.value(`courseID`))) as? (String) -> Void - perform?(`courseID`) - var __value: CourseDates + open func saveCourseCalendarEvent(_ event: CourseCalendarEvent) { + addInvocation(.m_saveCourseCalendarEvent__event(Parameter.value(`event`))) + let perform = methodPerformValue(.m_saveCourseCalendarEvent__event(Parameter.value(`event`))) as? (CourseCalendarEvent) -> Void + perform?(`event`) + } + + open func removeCourseCalendarEvents(for courseId: String) { + addInvocation(.m_removeCourseCalendarEvents__for_courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_removeCourseCalendarEvents__for_courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + } + + open func removeAllCourseCalendarEvents() { + addInvocation(.m_removeAllCourseCalendarEvents) + let perform = methodPerformValue(.m_removeAllCourseCalendarEvents) as? () -> Void + perform?() + } + + open func getCourseCalendarEvents(for courseId: String) -> [CourseCalendarEvent] { + addInvocation(.m_getCourseCalendarEvents__for_courseId(Parameter.value(`courseId`))) + let perform = methodPerformValue(.m_getCourseCalendarEvents__for_courseId(Parameter.value(`courseId`))) as? (String) -> Void + perform?(`courseId`) + var __value: [CourseCalendarEvent] do { - __value = try methodReturnValue(.m_getCourseDates__courseID_courseID(Parameter.value(`courseID`))).casted() - } catch MockError.notStubed { - onFatalFailure("Stub return value not specified for getCourseDates(courseID: String). Use given") - Failure("Stub return value not specified for getCourseDates(courseID: String). Use given") + __value = try methodReturnValue(.m_getCourseCalendarEvents__for_courseId(Parameter.value(`courseId`))).casted() } catch { - throw error + onFatalFailure("Stub return value not specified for getCourseCalendarEvents(for courseId: String). Use given") + Failure("Stub return value not specified for getCourseCalendarEvents(for courseId: String). Use given") } return __value } fileprivate enum MethodType { - case m_getUserProfile__username_username(Parameter) - case m_getMyProfile - case m_getMyProfileOffline - case m_logOut - case m_getSpokenLanguages - case m_getCountries - case m_uploadProfilePicture__pictureData_pictureData(Parameter) - case m_deleteProfilePicture - case m_updateUserProfile__parameters_parameters(Parameter<[String: Any]>) - case m_deleteAccount__password_password(Parameter) - case m_getSettings - case m_saveSettings__settings(Parameter) - case m_enrollmentsStatus - case m_getCourseDates__courseID_courseID(Parameter) + case m_getCourseState__courseID_courseID(Parameter) + case m_getAllCourseStates + case m_saveCourseState__state_state(Parameter) + case m_removeCourseState__courseID_courseID(Parameter) + case m_deleteAllCourseStatesAndEvents + case m_saveCourseCalendarEvent__event(Parameter) + case m_removeCourseCalendarEvents__for_courseId(Parameter) + case m_removeAllCourseCalendarEvents + case m_getCourseCalendarEvents__for_courseId(Parameter) static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { - case (.m_getUserProfile__username_username(let lhsUsername), .m_getUserProfile__username_username(let rhsUsername)): + case (.m_getCourseState__courseID_courseID(let lhsCourseid), .m_getCourseState__courseID_courseID(let rhsCourseid)): var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsUsername, rhs: rhsUsername, with: matcher), lhsUsername, rhsUsername, "username")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) return Matcher.ComparisonResult(results) - case (.m_getMyProfile, .m_getMyProfile): return .match - - case (.m_getMyProfileOffline, .m_getMyProfileOffline): return .match - - case (.m_logOut, .m_logOut): return .match - - case (.m_getSpokenLanguages, .m_getSpokenLanguages): return .match - - case (.m_getCountries, .m_getCountries): return .match + case (.m_getAllCourseStates, .m_getAllCourseStates): return .match - case (.m_uploadProfilePicture__pictureData_pictureData(let lhsPicturedata), .m_uploadProfilePicture__pictureData_pictureData(let rhsPicturedata)): + case (.m_saveCourseState__state_state(let lhsState), .m_saveCourseState__state_state(let rhsState)): var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsPicturedata, rhs: rhsPicturedata, with: matcher), lhsPicturedata, rhsPicturedata, "pictureData")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsState, rhs: rhsState, with: matcher), lhsState, rhsState, "state")) return Matcher.ComparisonResult(results) - case (.m_deleteProfilePicture, .m_deleteProfilePicture): return .match - - case (.m_updateUserProfile__parameters_parameters(let lhsParameters), .m_updateUserProfile__parameters_parameters(let rhsParameters)): + case (.m_removeCourseState__courseID_courseID(let lhsCourseid), .m_removeCourseState__courseID_courseID(let rhsCourseid)): var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsParameters, rhs: rhsParameters, with: matcher), lhsParameters, rhsParameters, "parameters")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "courseID")) return Matcher.ComparisonResult(results) - case (.m_deleteAccount__password_password(let lhsPassword), .m_deleteAccount__password_password(let rhsPassword)): + case (.m_deleteAllCourseStatesAndEvents, .m_deleteAllCourseStatesAndEvents): return .match + + case (.m_saveCourseCalendarEvent__event(let lhsEvent), .m_saveCourseCalendarEvent__event(let rhsEvent)): var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsPassword, rhs: rhsPassword, with: matcher), lhsPassword, rhsPassword, "password")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsEvent, rhs: rhsEvent, with: matcher), lhsEvent, rhsEvent, "_ event")) return Matcher.ComparisonResult(results) - case (.m_getSettings, .m_getSettings): return .match - - case (.m_saveSettings__settings(let lhsSettings), .m_saveSettings__settings(let rhsSettings)): + case (.m_removeCourseCalendarEvents__for_courseId(let lhsCourseid), .m_removeCourseCalendarEvents__for_courseId(let rhsCourseid)): var results: [Matcher.ParameterComparisonResult] = [] - results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsSettings, rhs: rhsSettings, with: matcher), lhsSettings, rhsSettings, "_ settings")) + results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "for courseId")) return Matcher.ComparisonResult(results) - case (.m_enrollmentsStatus, .m_enrollmentsStatus): return .match + case (.m_removeAllCourseCalendarEvents, .m_removeAllCourseCalendarEvents): return .match - case (.m_getCourseDates__courseID_courseID(let lhsCourseid), .m_getCourseDates__courseID_courseID(let rhsCourseid)): + case (.m_getCourseCalendarEvents__for_courseId(let lhsCourseid), .m_getCourseCalendarEvents__for_courseId(let rhsCourseid)): 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: lhsCourseid, rhs: rhsCourseid, with: matcher), lhsCourseid, rhsCourseid, "for courseId")) return Matcher.ComparisonResult(results) default: return .none } @@ -4139,38 +5546,28 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { func intValue() -> Int { switch self { - case let .m_getUserProfile__username_username(p0): return p0.intValue - case .m_getMyProfile: return 0 - case .m_getMyProfileOffline: return 0 - case .m_logOut: return 0 - case .m_getSpokenLanguages: return 0 - case .m_getCountries: return 0 - case let .m_uploadProfilePicture__pictureData_pictureData(p0): return p0.intValue - case .m_deleteProfilePicture: return 0 - case let .m_updateUserProfile__parameters_parameters(p0): return p0.intValue - case let .m_deleteAccount__password_password(p0): return p0.intValue - case .m_getSettings: return 0 - case let .m_saveSettings__settings(p0): return p0.intValue - case .m_enrollmentsStatus: return 0 - case let .m_getCourseDates__courseID_courseID(p0): return p0.intValue + case let .m_getCourseState__courseID_courseID(p0): return p0.intValue + case .m_getAllCourseStates: return 0 + case let .m_saveCourseState__state_state(p0): return p0.intValue + case let .m_removeCourseState__courseID_courseID(p0): return p0.intValue + case .m_deleteAllCourseStatesAndEvents: return 0 + case let .m_saveCourseCalendarEvent__event(p0): return p0.intValue + case let .m_removeCourseCalendarEvents__for_courseId(p0): return p0.intValue + case .m_removeAllCourseCalendarEvents: return 0 + case let .m_getCourseCalendarEvents__for_courseId(p0): return p0.intValue } } func assertionName() -> String { switch self { - case .m_getUserProfile__username_username: return ".getUserProfile(username:)" - case .m_getMyProfile: return ".getMyProfile()" - case .m_getMyProfileOffline: return ".getMyProfileOffline()" - case .m_logOut: return ".logOut()" - case .m_getSpokenLanguages: return ".getSpokenLanguages()" - case .m_getCountries: return ".getCountries()" - case .m_uploadProfilePicture__pictureData_pictureData: return ".uploadProfilePicture(pictureData:)" - case .m_deleteProfilePicture: return ".deleteProfilePicture()" - case .m_updateUserProfile__parameters_parameters: return ".updateUserProfile(parameters:)" - case .m_deleteAccount__password_password: return ".deleteAccount(password:)" - case .m_getSettings: return ".getSettings()" - case .m_saveSettings__settings: return ".saveSettings(_:)" - case .m_enrollmentsStatus: return ".enrollmentsStatus()" - case .m_getCourseDates__courseID_courseID: return ".getCourseDates(courseID:)" + case .m_getCourseState__courseID_courseID: return ".getCourseState(courseID:)" + case .m_getAllCourseStates: return ".getAllCourseStates()" + case .m_saveCourseState__state_state: return ".saveCourseState(state:)" + case .m_removeCourseState__courseID_courseID: return ".removeCourseState(courseID:)" + case .m_deleteAllCourseStatesAndEvents: return ".deleteAllCourseStatesAndEvents()" + case .m_saveCourseCalendarEvent__event: return ".saveCourseCalendarEvent(_:)" + case .m_removeCourseCalendarEvents__for_courseId: return ".removeCourseCalendarEvents(for:)" + case .m_removeAllCourseCalendarEvents: return ".removeAllCourseCalendarEvents()" + case .m_getCourseCalendarEvents__for_courseId: return ".getCourseCalendarEvents(for:)" } } } @@ -4184,154 +5581,33 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { } - public static func getUserProfile(username: Parameter, willReturn: UserProfile...) -> MethodStub { - return Given(method: .m_getUserProfile__username_username(`username`), products: willReturn.map({ StubProduct.return($0 as Any) })) - } - public static func getMyProfile(willReturn: UserProfile...) -> MethodStub { - return Given(method: .m_getMyProfile, products: willReturn.map({ StubProduct.return($0 as Any) })) - } - public static func getMyProfileOffline(willReturn: UserProfile?...) -> MethodStub { - return Given(method: .m_getMyProfileOffline, products: willReturn.map({ StubProduct.return($0 as Any) })) - } - public static func getSpokenLanguages(willReturn: [PickerFields.Option]...) -> MethodStub { - return Given(method: .m_getSpokenLanguages, products: willReturn.map({ StubProduct.return($0 as Any) })) - } - public static func getCountries(willReturn: [PickerFields.Option]...) -> MethodStub { - return Given(method: .m_getCountries, products: willReturn.map({ StubProduct.return($0 as Any) })) - } - public static func deleteProfilePicture(willReturn: Bool...) -> MethodStub { - return Given(method: .m_deleteProfilePicture, products: willReturn.map({ StubProduct.return($0 as Any) })) - } - public static func updateUserProfile(parameters: Parameter<[String: Any]>, willReturn: UserProfile...) -> MethodStub { - return Given(method: .m_updateUserProfile__parameters_parameters(`parameters`), products: willReturn.map({ StubProduct.return($0 as Any) })) - } - public static func deleteAccount(password: Parameter, willReturn: Bool...) -> MethodStub { - return Given(method: .m_deleteAccount__password_password(`password`), products: willReturn.map({ StubProduct.return($0 as Any) })) - } - public static func getSettings(willReturn: UserSettings...) -> MethodStub { - return Given(method: .m_getSettings, products: willReturn.map({ StubProduct.return($0 as Any) })) - } - public static func enrollmentsStatus(willReturn: [CourseForSync]...) -> MethodStub { - return Given(method: .m_enrollmentsStatus, products: willReturn.map({ StubProduct.return($0 as Any) })) - } - public static func getCourseDates(courseID: Parameter, willReturn: CourseDates...) -> MethodStub { - return Given(method: .m_getCourseDates__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) - } - public static func getMyProfileOffline(willProduce: (Stubber) -> Void) -> MethodStub { - let willReturn: [UserProfile?] = [] - let given: Given = { return Given(method: .m_getMyProfileOffline, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (UserProfile?).self) - willProduce(stubber) - return given - } - public static func getSpokenLanguages(willProduce: (Stubber<[PickerFields.Option]>) -> Void) -> MethodStub { - let willReturn: [[PickerFields.Option]] = [] - let given: Given = { return Given(method: .m_getSpokenLanguages, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: ([PickerFields.Option]).self) - willProduce(stubber) - return given - } - public static func getCountries(willProduce: (Stubber<[PickerFields.Option]>) -> Void) -> MethodStub { - let willReturn: [[PickerFields.Option]] = [] - let given: Given = { return Given(method: .m_getCountries, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: ([PickerFields.Option]).self) - willProduce(stubber) - return given - } - public static func getSettings(willProduce: (Stubber) -> Void) -> MethodStub { - let willReturn: [UserSettings] = [] - let given: Given = { return Given(method: .m_getSettings, products: willReturn.map({ StubProduct.return($0 as Any) })) }() - let stubber = given.stub(for: (UserSettings).self) - willProduce(stubber) - return given - } - public static func getUserProfile(username: Parameter, willThrow: Error...) -> MethodStub { - return Given(method: .m_getUserProfile__username_username(`username`), products: willThrow.map({ StubProduct.throw($0) })) - } - public static func getUserProfile(username: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { - let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_getUserProfile__username_username(`username`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (UserProfile).self) - willProduce(stubber) - return given - } - public static func getMyProfile(willThrow: Error...) -> MethodStub { - return Given(method: .m_getMyProfile, products: willThrow.map({ StubProduct.throw($0) })) - } - public static func getMyProfile(willProduce: (StubberThrows) -> Void) -> MethodStub { - let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_getMyProfile, products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (UserProfile).self) - willProduce(stubber) - return given - } - public static func logOut(willThrow: Error...) -> MethodStub { - return Given(method: .m_logOut, products: willThrow.map({ StubProduct.throw($0) })) - } - public static func logOut(willProduce: (StubberThrows) -> Void) -> MethodStub { - let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_logOut, products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (Void).self) - willProduce(stubber) - return given - } - public static func uploadProfilePicture(pictureData: Parameter, willThrow: Error...) -> MethodStub { - return Given(method: .m_uploadProfilePicture__pictureData_pictureData(`pictureData`), products: willThrow.map({ StubProduct.throw($0) })) - } - public static func uploadProfilePicture(pictureData: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { - let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_uploadProfilePicture__pictureData_pictureData(`pictureData`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (Void).self) - willProduce(stubber) - return given - } - public static func deleteProfilePicture(willThrow: Error...) -> MethodStub { - return Given(method: .m_deleteProfilePicture, products: willThrow.map({ StubProduct.throw($0) })) - } - public static func deleteProfilePicture(willProduce: (StubberThrows) -> Void) -> MethodStub { - let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_deleteProfilePicture, products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (Bool).self) - willProduce(stubber) - return given - } - public static func updateUserProfile(parameters: Parameter<[String: Any]>, willThrow: Error...) -> MethodStub { - return Given(method: .m_updateUserProfile__parameters_parameters(`parameters`), products: willThrow.map({ StubProduct.throw($0) })) - } - public static func updateUserProfile(parameters: Parameter<[String: Any]>, willProduce: (StubberThrows) -> Void) -> MethodStub { - let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_updateUserProfile__parameters_parameters(`parameters`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (UserProfile).self) - willProduce(stubber) - return given - } - public static func deleteAccount(password: Parameter, willThrow: Error...) -> MethodStub { - return Given(method: .m_deleteAccount__password_password(`password`), products: willThrow.map({ StubProduct.throw($0) })) - } - public static func deleteAccount(password: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { - let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_deleteAccount__password_password(`password`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (Bool).self) - willProduce(stubber) - return given + public static func getCourseState(courseID: Parameter, willReturn: CourseCalendarState?...) -> MethodStub { + return Given(method: .m_getCourseState__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func enrollmentsStatus(willThrow: Error...) -> MethodStub { - return Given(method: .m_enrollmentsStatus, products: willThrow.map({ StubProduct.throw($0) })) + public static func getAllCourseStates(willReturn: [CourseCalendarState]...) -> MethodStub { + return Given(method: .m_getAllCourseStates, products: willReturn.map({ StubProduct.return($0 as Any) })) } - public static func enrollmentsStatus(willProduce: (StubberThrows<[CourseForSync]>) -> Void) -> MethodStub { - let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_enrollmentsStatus, products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: ([CourseForSync]).self) + public static func getCourseCalendarEvents(for courseId: Parameter, willReturn: [CourseCalendarEvent]...) -> MethodStub { + return Given(method: .m_getCourseCalendarEvents__for_courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) + } + public static func getCourseState(courseID: Parameter, willProduce: (Stubber) -> Void) -> MethodStub { + let willReturn: [CourseCalendarState?] = [] + let given: Given = { return Given(method: .m_getCourseState__courseID_courseID(`courseID`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: (CourseCalendarState?).self) willProduce(stubber) return given } - public static func getCourseDates(courseID: Parameter, willThrow: Error...) -> MethodStub { - return Given(method: .m_getCourseDates__courseID_courseID(`courseID`), products: willThrow.map({ StubProduct.throw($0) })) + public static func getAllCourseStates(willProduce: (Stubber<[CourseCalendarState]>) -> Void) -> MethodStub { + let willReturn: [[CourseCalendarState]] = [] + let given: Given = { return Given(method: .m_getAllCourseStates, products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseCalendarState]).self) + willProduce(stubber) + return given } - public static func getCourseDates(courseID: Parameter, willProduce: (StubberThrows) -> Void) -> MethodStub { - let willThrow: [Error] = [] - let given: Given = { return Given(method: .m_getCourseDates__courseID_courseID(`courseID`), products: willThrow.map({ StubProduct.throw($0) })) }() - let stubber = given.stubThrows(for: (CourseDates).self) + public static func getCourseCalendarEvents(for courseId: Parameter, willProduce: (Stubber<[CourseCalendarEvent]>) -> Void) -> MethodStub { + let willReturn: [[CourseCalendarEvent]] = [] + let given: Given = { return Given(method: .m_getCourseCalendarEvents__for_courseId(`courseId`), products: willReturn.map({ StubProduct.return($0 as Any) })) }() + let stubber = given.stub(for: ([CourseCalendarEvent]).self) willProduce(stubber) return given } @@ -4340,67 +5616,47 @@ open class ProfileInteractorProtocolMock: ProfileInteractorProtocol, Mock { public struct Verify { fileprivate var method: MethodType - public static func getUserProfile(username: Parameter) -> Verify { return Verify(method: .m_getUserProfile__username_username(`username`))} - public static func getMyProfile() -> Verify { return Verify(method: .m_getMyProfile)} - public static func getMyProfileOffline() -> Verify { return Verify(method: .m_getMyProfileOffline)} - public static func logOut() -> Verify { return Verify(method: .m_logOut)} - public static func getSpokenLanguages() -> Verify { return Verify(method: .m_getSpokenLanguages)} - public static func getCountries() -> Verify { return Verify(method: .m_getCountries)} - public static func uploadProfilePicture(pictureData: Parameter) -> Verify { return Verify(method: .m_uploadProfilePicture__pictureData_pictureData(`pictureData`))} - public static func deleteProfilePicture() -> Verify { return Verify(method: .m_deleteProfilePicture)} - public static func updateUserProfile(parameters: Parameter<[String: Any]>) -> Verify { return Verify(method: .m_updateUserProfile__parameters_parameters(`parameters`))} - public static func deleteAccount(password: Parameter) -> Verify { return Verify(method: .m_deleteAccount__password_password(`password`))} - public static func getSettings() -> Verify { return Verify(method: .m_getSettings)} - public static func saveSettings(_ settings: Parameter) -> Verify { return Verify(method: .m_saveSettings__settings(`settings`))} - public static func enrollmentsStatus() -> Verify { return Verify(method: .m_enrollmentsStatus)} - public static func getCourseDates(courseID: Parameter) -> Verify { return Verify(method: .m_getCourseDates__courseID_courseID(`courseID`))} + public static func getCourseState(courseID: Parameter) -> Verify { return Verify(method: .m_getCourseState__courseID_courseID(`courseID`))} + public static func getAllCourseStates() -> Verify { return Verify(method: .m_getAllCourseStates)} + public static func saveCourseState(state: Parameter) -> Verify { return Verify(method: .m_saveCourseState__state_state(`state`))} + public static func removeCourseState(courseID: Parameter) -> Verify { return Verify(method: .m_removeCourseState__courseID_courseID(`courseID`))} + public static func deleteAllCourseStatesAndEvents() -> Verify { return Verify(method: .m_deleteAllCourseStatesAndEvents)} + public static func saveCourseCalendarEvent(_ event: Parameter) -> Verify { return Verify(method: .m_saveCourseCalendarEvent__event(`event`))} + public static func removeCourseCalendarEvents(for courseId: Parameter) -> Verify { return Verify(method: .m_removeCourseCalendarEvents__for_courseId(`courseId`))} + public static func removeAllCourseCalendarEvents() -> Verify { return Verify(method: .m_removeAllCourseCalendarEvents)} + public static func getCourseCalendarEvents(for courseId: Parameter) -> Verify { return Verify(method: .m_getCourseCalendarEvents__for_courseId(`courseId`))} } public struct Perform { fileprivate var method: MethodType var performs: Any - public static func getUserProfile(username: Parameter, perform: @escaping (String) -> Void) -> Perform { - return Perform(method: .m_getUserProfile__username_username(`username`), performs: perform) - } - public static func getMyProfile(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_getMyProfile, performs: perform) - } - public static func getMyProfileOffline(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_getMyProfileOffline, performs: perform) - } - public static func logOut(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_logOut, performs: perform) + public static func getCourseState(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getCourseState__courseID_courseID(`courseID`), performs: perform) } - public static func getSpokenLanguages(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_getSpokenLanguages, performs: perform) - } - public static func getCountries(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_getCountries, performs: perform) - } - public static func uploadProfilePicture(pictureData: Parameter, perform: @escaping (Data) -> Void) -> Perform { - return Perform(method: .m_uploadProfilePicture__pictureData_pictureData(`pictureData`), performs: perform) + public static func getAllCourseStates(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_getAllCourseStates, performs: perform) } - public static func deleteProfilePicture(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_deleteProfilePicture, performs: perform) + public static func saveCourseState(state: Parameter, perform: @escaping (CourseCalendarState) -> Void) -> Perform { + return Perform(method: .m_saveCourseState__state_state(`state`), performs: perform) } - public static func updateUserProfile(parameters: Parameter<[String: Any]>, perform: @escaping ([String: Any]) -> Void) -> Perform { - return Perform(method: .m_updateUserProfile__parameters_parameters(`parameters`), performs: perform) + public static func removeCourseState(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_removeCourseState__courseID_courseID(`courseID`), performs: perform) } - public static func deleteAccount(password: Parameter, perform: @escaping (String) -> Void) -> Perform { - return Perform(method: .m_deleteAccount__password_password(`password`), performs: perform) + public static func deleteAllCourseStatesAndEvents(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_deleteAllCourseStatesAndEvents, performs: perform) } - public static func getSettings(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_getSettings, performs: perform) + public static func saveCourseCalendarEvent(_ event: Parameter, perform: @escaping (CourseCalendarEvent) -> Void) -> Perform { + return Perform(method: .m_saveCourseCalendarEvent__event(`event`), performs: perform) } - public static func saveSettings(_ settings: Parameter, perform: @escaping (UserSettings) -> Void) -> Perform { - return Perform(method: .m_saveSettings__settings(`settings`), performs: perform) + public static func removeCourseCalendarEvents(for courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_removeCourseCalendarEvents__for_courseId(`courseId`), performs: perform) } - public static func enrollmentsStatus(perform: @escaping () -> Void) -> Perform { - return Perform(method: .m_enrollmentsStatus, performs: perform) + public static func removeAllCourseCalendarEvents(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_removeAllCourseCalendarEvents, performs: perform) } - public static func getCourseDates(courseID: Parameter, perform: @escaping (String) -> Void) -> Perform { - return Perform(method: .m_getCourseDates__courseID_courseID(`courseID`), performs: perform) + public static func getCourseCalendarEvents(for courseId: Parameter, perform: @escaping (String) -> Void) -> Perform { + return Perform(method: .m_getCourseCalendarEvents__for_courseId(`courseId`), performs: perform) } } @@ -5112,6 +6368,315 @@ open class ProfileRouterMock: ProfileRouter, Mock { } } +// MARK: - ProfileStorage + +open class ProfileStorageMock: ProfileStorage, 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 userProfile: DataLayer.UserProfile? { + get { invocations.append(.p_userProfile_get); return __p_userProfile ?? optionalGivenGetterValue(.p_userProfile_get, "ProfileStorageMock - stub value for userProfile was not defined") } + set { invocations.append(.p_userProfile_set(.value(newValue))); __p_userProfile = newValue } + } + private var __p_userProfile: (DataLayer.UserProfile)? + + public var useRelativeDates: Bool { + get { invocations.append(.p_useRelativeDates_get); return __p_useRelativeDates ?? givenGetterValue(.p_useRelativeDates_get, "ProfileStorageMock - stub value for useRelativeDates was not defined") } + set { invocations.append(.p_useRelativeDates_set(.value(newValue))); __p_useRelativeDates = newValue } + } + private var __p_useRelativeDates: (Bool)? + + public var calendarSettings: CalendarSettings? { + get { invocations.append(.p_calendarSettings_get); return __p_calendarSettings ?? optionalGivenGetterValue(.p_calendarSettings_get, "ProfileStorageMock - stub value for calendarSettings was not defined") } + set { invocations.append(.p_calendarSettings_set(.value(newValue))); __p_calendarSettings = newValue } + } + private var __p_calendarSettings: (CalendarSettings)? + + public var hideInactiveCourses: Bool? { + get { invocations.append(.p_hideInactiveCourses_get); return __p_hideInactiveCourses ?? optionalGivenGetterValue(.p_hideInactiveCourses_get, "ProfileStorageMock - stub value for hideInactiveCourses was not defined") } + set { invocations.append(.p_hideInactiveCourses_set(.value(newValue))); __p_hideInactiveCourses = newValue } + } + private var __p_hideInactiveCourses: (Bool)? + + public var lastLoginUsername: String? { + get { invocations.append(.p_lastLoginUsername_get); return __p_lastLoginUsername ?? optionalGivenGetterValue(.p_lastLoginUsername_get, "ProfileStorageMock - stub value for lastLoginUsername was not defined") } + set { invocations.append(.p_lastLoginUsername_set(.value(newValue))); __p_lastLoginUsername = newValue } + } + private var __p_lastLoginUsername: (String)? + + public var lastCalendarName: String? { + get { invocations.append(.p_lastCalendarName_get); return __p_lastCalendarName ?? optionalGivenGetterValue(.p_lastCalendarName_get, "ProfileStorageMock - stub value for lastCalendarName was not defined") } + set { invocations.append(.p_lastCalendarName_set(.value(newValue))); __p_lastCalendarName = newValue } + } + private var __p_lastCalendarName: (String)? + + public var lastCalendarUpdateDate: Date? { + get { invocations.append(.p_lastCalendarUpdateDate_get); return __p_lastCalendarUpdateDate ?? optionalGivenGetterValue(.p_lastCalendarUpdateDate_get, "ProfileStorageMock - stub value for lastCalendarUpdateDate was not defined") } + set { invocations.append(.p_lastCalendarUpdateDate_set(.value(newValue))); __p_lastCalendarUpdateDate = newValue } + } + private var __p_lastCalendarUpdateDate: (Date)? + + public var firstCalendarUpdate: Bool? { + get { invocations.append(.p_firstCalendarUpdate_get); return __p_firstCalendarUpdate ?? optionalGivenGetterValue(.p_firstCalendarUpdate_get, "ProfileStorageMock - stub value for firstCalendarUpdate was not defined") } + set { invocations.append(.p_firstCalendarUpdate_set(.value(newValue))); __p_firstCalendarUpdate = newValue } + } + private var __p_firstCalendarUpdate: (Bool)? + + + + + + + fileprivate enum MethodType { + case p_userProfile_get + case p_userProfile_set(Parameter) + case p_useRelativeDates_get + case p_useRelativeDates_set(Parameter) + case p_calendarSettings_get + case p_calendarSettings_set(Parameter) + case p_hideInactiveCourses_get + case p_hideInactiveCourses_set(Parameter) + case p_lastLoginUsername_get + case p_lastLoginUsername_set(Parameter) + case p_lastCalendarName_get + case p_lastCalendarName_set(Parameter) + case p_lastCalendarUpdateDate_get + case p_lastCalendarUpdateDate_set(Parameter) + case p_firstCalendarUpdate_get + case p_firstCalendarUpdate_set(Parameter) + + static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { + switch (lhs, rhs) { case (.p_userProfile_get,.p_userProfile_get): return Matcher.ComparisonResult.match + case (.p_userProfile_set(let left),.p_userProfile_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_useRelativeDates_get,.p_useRelativeDates_get): return Matcher.ComparisonResult.match + case (.p_useRelativeDates_set(let left),.p_useRelativeDates_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_calendarSettings_get,.p_calendarSettings_get): return Matcher.ComparisonResult.match + case (.p_calendarSettings_set(let left),.p_calendarSettings_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_hideInactiveCourses_get,.p_hideInactiveCourses_get): return Matcher.ComparisonResult.match + case (.p_hideInactiveCourses_set(let left),.p_hideInactiveCourses_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_lastLoginUsername_get,.p_lastLoginUsername_get): return Matcher.ComparisonResult.match + case (.p_lastLoginUsername_set(let left),.p_lastLoginUsername_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_lastCalendarName_get,.p_lastCalendarName_get): return Matcher.ComparisonResult.match + case (.p_lastCalendarName_set(let left),.p_lastCalendarName_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_lastCalendarUpdateDate_get,.p_lastCalendarUpdateDate_get): return Matcher.ComparisonResult.match + case (.p_lastCalendarUpdateDate_set(let left),.p_lastCalendarUpdateDate_set(let right)): return Matcher.ComparisonResult([Matcher.ParameterComparisonResult(Parameter.compare(lhs: left, rhs: right, with: matcher), left, right, "newValue")]) + case (.p_firstCalendarUpdate_get,.p_firstCalendarUpdate_get): return Matcher.ComparisonResult.match + case (.p_firstCalendarUpdate_set(let left),.p_firstCalendarUpdate_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 .p_userProfile_get: return 0 + case .p_userProfile_set(let newValue): return newValue.intValue + case .p_useRelativeDates_get: return 0 + case .p_useRelativeDates_set(let newValue): return newValue.intValue + case .p_calendarSettings_get: return 0 + case .p_calendarSettings_set(let newValue): return newValue.intValue + case .p_hideInactiveCourses_get: return 0 + case .p_hideInactiveCourses_set(let newValue): return newValue.intValue + case .p_lastLoginUsername_get: return 0 + case .p_lastLoginUsername_set(let newValue): return newValue.intValue + case .p_lastCalendarName_get: return 0 + case .p_lastCalendarName_set(let newValue): return newValue.intValue + case .p_lastCalendarUpdateDate_get: return 0 + case .p_lastCalendarUpdateDate_set(let newValue): return newValue.intValue + case .p_firstCalendarUpdate_get: return 0 + case .p_firstCalendarUpdate_set(let newValue): return newValue.intValue + } + } + func assertionName() -> String { + switch self { + case .p_userProfile_get: return "[get] .userProfile" + case .p_userProfile_set: return "[set] .userProfile" + case .p_useRelativeDates_get: return "[get] .useRelativeDates" + case .p_useRelativeDates_set: return "[set] .useRelativeDates" + case .p_calendarSettings_get: return "[get] .calendarSettings" + case .p_calendarSettings_set: return "[set] .calendarSettings" + case .p_hideInactiveCourses_get: return "[get] .hideInactiveCourses" + case .p_hideInactiveCourses_set: return "[set] .hideInactiveCourses" + case .p_lastLoginUsername_get: return "[get] .lastLoginUsername" + case .p_lastLoginUsername_set: return "[set] .lastLoginUsername" + case .p_lastCalendarName_get: return "[get] .lastCalendarName" + case .p_lastCalendarName_set: return "[set] .lastCalendarName" + case .p_lastCalendarUpdateDate_get: return "[get] .lastCalendarUpdateDate" + case .p_lastCalendarUpdateDate_set: return "[set] .lastCalendarUpdateDate" + case .p_firstCalendarUpdate_get: return "[get] .firstCalendarUpdate" + case .p_firstCalendarUpdate_set: return "[set] .firstCalendarUpdate" + } + } + } + + open class Given: StubbedMethod { + fileprivate var method: MethodType + + private init(method: MethodType, products: [StubProduct]) { + self.method = method + super.init(products) + } + + public static func userProfile(getter defaultValue: DataLayer.UserProfile?...) -> PropertyStub { + return Given(method: .p_userProfile_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func useRelativeDates(getter defaultValue: Bool...) -> PropertyStub { + return Given(method: .p_useRelativeDates_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func calendarSettings(getter defaultValue: CalendarSettings?...) -> PropertyStub { + return Given(method: .p_calendarSettings_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func hideInactiveCourses(getter defaultValue: Bool?...) -> PropertyStub { + return Given(method: .p_hideInactiveCourses_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func lastLoginUsername(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_lastLoginUsername_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func lastCalendarName(getter defaultValue: String?...) -> PropertyStub { + return Given(method: .p_lastCalendarName_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func lastCalendarUpdateDate(getter defaultValue: Date?...) -> PropertyStub { + return Given(method: .p_lastCalendarUpdateDate_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + public static func firstCalendarUpdate(getter defaultValue: Bool?...) -> PropertyStub { + return Given(method: .p_firstCalendarUpdate_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) + } + + } + + public struct Verify { + fileprivate var method: MethodType + + public static var userProfile: Verify { return Verify(method: .p_userProfile_get) } + public static func userProfile(set newValue: Parameter) -> Verify { return Verify(method: .p_userProfile_set(newValue)) } + public static var useRelativeDates: Verify { return Verify(method: .p_useRelativeDates_get) } + public static func useRelativeDates(set newValue: Parameter) -> Verify { return Verify(method: .p_useRelativeDates_set(newValue)) } + public static var calendarSettings: Verify { return Verify(method: .p_calendarSettings_get) } + public static func calendarSettings(set newValue: Parameter) -> Verify { return Verify(method: .p_calendarSettings_set(newValue)) } + public static var hideInactiveCourses: Verify { return Verify(method: .p_hideInactiveCourses_get) } + public static func hideInactiveCourses(set newValue: Parameter) -> Verify { return Verify(method: .p_hideInactiveCourses_set(newValue)) } + public static var lastLoginUsername: Verify { return Verify(method: .p_lastLoginUsername_get) } + public static func lastLoginUsername(set newValue: Parameter) -> Verify { return Verify(method: .p_lastLoginUsername_set(newValue)) } + public static var lastCalendarName: Verify { return Verify(method: .p_lastCalendarName_get) } + public static func lastCalendarName(set newValue: Parameter) -> Verify { return Verify(method: .p_lastCalendarName_set(newValue)) } + public static var lastCalendarUpdateDate: Verify { return Verify(method: .p_lastCalendarUpdateDate_get) } + public static func lastCalendarUpdateDate(set newValue: Parameter) -> Verify { return Verify(method: .p_lastCalendarUpdateDate_set(newValue)) } + public static var firstCalendarUpdate: Verify { return Verify(method: .p_firstCalendarUpdate_get) } + public static func firstCalendarUpdate(set newValue: Parameter) -> Verify { return Verify(method: .p_firstCalendarUpdate_set(newValue)) } + } + + public struct Perform { + fileprivate var method: MethodType + var performs: Any + + } + + 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 { From a38730c73ac57cfc9166e3592aefa5103d8ea2ab Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Mon, 4 Nov 2024 14:16:24 +0100 Subject: [PATCH 52/55] fix: decrease facebook sdk version and fix open(url) calling (#537) Co-authored-by: Anton Yarmolenko <37253+rnr@users.noreply.github.com> --- Core/Core.xcodeproj/project.pbxproj | 2 +- .../Presentation/Unit/Subviews/NotAvailableOnMobileView.swift | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 081a4c261..3ba4ab39b 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -2466,7 +2466,7 @@ repositoryURL = "https://github.com/facebook/facebook-ios-sdk"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 17.4.0; + minimumVersion = 16.3.1; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Course/Course/Presentation/Unit/Subviews/NotAvailableOnMobileView.swift b/Course/Course/Presentation/Unit/Subviews/NotAvailableOnMobileView.swift index 49741df69..dac965193 100644 --- a/Course/Course/Presentation/Unit/Subviews/NotAvailableOnMobileView.swift +++ b/Course/Course/Presentation/Unit/Subviews/NotAvailableOnMobileView.swift @@ -31,7 +31,8 @@ public struct NotAvailableOnMobileView: View { .padding(.top, 12) StyledButton(CourseLocalization.NotAvaliable.button, action: { if let url = URL(string: url), UIApplication.shared.canOpenURL(url) { - UIApplication.shared.open(url) + // Added empty options to avoid calling overridden open(url) function in facebook sdk + UIApplication.shared.open(url, options: [:]) } }) .frame(width: 215) From 5645bce069b75bc5eecd5d522037d1d86aa4127b Mon Sep 17 00:00:00 2001 From: IvanStepanok <128456094+IvanStepanok@users.noreply.github.com> Date: Tue, 5 Nov 2024 21:40:44 +0200 Subject: [PATCH 53/55] [FC-0072] iOS Mobile Plugin Architecture (#528) * feat: connect oexfoundation library to project * fix: update library version * fix: change package version * feat: connect OEXFoundation to project * fix: update OEXFoundation library url and version * fix: address feedback * fix: update mocks * fix: update fastline * fix: address feedback * fix: move msal to main module * fix: address feedback --- .../Authorization.xcodeproj/project.pbxproj | 117 +++++++ .../Presentation/AuthorizationAnalytics.swift | 1 + .../Presentation/Login/SignInView.swift | 1 + .../Presentation/Login/SignInViewModel.swift | 1 + .../Registration/SignUpView.swift | 1 + .../Registration/SignUpViewModel.swift | 1 + .../Reset Password/ResetPasswordView.swift | 1 + .../ResetPasswordViewModel.swift | 1 + .../Presentation/SSO/SSOWebViewModel.swift | 1 + .../SocialAuth/AppleAuthProvider.swift | 2 + .../SocialAuth/Error/SocialAuthError.swift | 1 + .../SocialAuth/FacebookAuthProvider.swift | 1 + .../SocialAuth/GoogleAuthProvider.swift | 1 + .../SocialAuth/MicrosoftAuthProvider.swift | 2 + .../SocialAuth/SocialAuthResponse.swift | 0 .../AuthorizationMock.generated.swift | 1 + .../Login/ResetPasswordViewModelTests.swift | 1 + .../Login/SignInViewModelTests.swift | 1 + .../Register/SignUpViewModelTests.swift | 1 + Authorization/Mockfile | 3 +- Core/Core.xcodeproj/project.pbxproj | 255 +++------------ .../Scroller/KeyboardScroller.swift | 3 +- .../Config/AgreementConfig.swift | 1 + .../Configuration/Config/BranchConfig.swift | 1 + .../Configuration/Config/BrazeConfig.swift | 1 + Core/Core/Configuration/Config/Config.swift | 2 - .../Config/DashboardConfig.swift | 1 + .../Config/DiscoveryConfig.swift | 1 + .../Config/FullStoryConfig.swift | 30 -- .../Config/MicrosoftConfig.swift | 2 +- .../Configuration/Config/SegmentConfig.swift | 31 -- .../Config/UIComponentsConfig.swift | 1 + .../Core/Data/Repository/AuthRepository.swift | 1 + .../Repository/OfflineSyncRepository.swift | 1 + .../AVPlayerViewControllerExtension.swift | 17 - Core/Core/Extensions/Bundle.swift | 15 - Core/Core/Extensions/CGColorExtension.swift | 33 -- .../Core/Extensions/CollectionExtension.swift | 15 - Core/Core/Extensions/Container+App.swift | 23 -- Core/Core/Extensions/DebugLog.swift | 25 -- Core/Core/Extensions/Dictionary+JSON.swift | 18 -- Core/Core/Extensions/DispatchQueue+App.swift | 16 - Core/Core/Extensions/IntExtension.swift | 25 -- Core/Core/Extensions/Notification.swift | 8 - .../Core/Extensions/RawStringExtactable.swift | 27 -- Core/Core/Extensions/ResultExtension.swift | 23 -- .../SKStoreReviewControllerExtension.swift | 20 -- .../Core/Extensions/Sequence+Extensions.swift | 14 - Core/Core/Extensions/String+JSON.swift | 23 -- Core/Core/Extensions/StringExtension.swift | 85 ----- Core/Core/Extensions/Thread.swift | 22 -- .../Extensions/UIApplicationExtension.swift | 106 ------ .../UINavigationController+Animation.swift | 41 --- .../UIResponder+CurrentResponder.swift | 26 -- .../UIView+EnclosingScrollView.swift | 18 -- Core/Core/Extensions/UrlExtension.swift | 26 -- Core/Core/Extensions/ViewExtension.swift | 143 -------- Core/Core/Network/API.swift | 304 ------------------ Core/Core/Network/Alamofire+Error.swift | 30 -- Core/Core/Network/AuthEndpoint.swift | 1 + Core/Core/Network/DownloadManager.swift | 3 +- Core/Core/Network/EndPointType.swift | 16 - Core/Core/Network/HTTPTask.swift | 17 - .../Core/Network/HeadersRedirectHandler.swift | 33 -- Core/Core/Network/NetworkLogger.swift | 48 --- Core/Core/Network/OfflineSyncEndpoint.swift | 1 + Core/Core/Network/OfflineSyncManager.swift | 1 + Core/Core/Network/UploadBodyEncoding.swift | 44 --- .../Base/BackNavigationButtonViewModel.swift | 1 + .../CoreTests/Configuration/ConfigTests.swift | 7 - .../Configuration/FullStoryConfigTests.swift | 57 ---- Core/CoreTests/CoreMock.generated.swift | 26 -- Course/Course.xcodeproj/project.pbxproj | 61 ++++ Course/Course/Data/CourseRepository.swift | 1 + .../Course/Data/Network/CourseEndpoint.swift | 1 + .../Container/CourseContainerViewModel.swift | 1 + .../Course/Presentation/CourseAnalytics.swift | 1 + .../Presentation/Dates/CourseDatesView.swift | 3 +- .../Dates/CourseDatesViewModel.swift | 1 + .../Downloads/DownloadsViewModel.swift | 1 + .../Presentation/Offline/OfflineView.swift | 1 + .../Outline/CourseOutlineView.swift | 5 +- .../CourseVertical/CourseVerticalView.swift | 2 +- .../CourseVideoDownloadBarViewModel.swift | 1 + .../Presentation/Unit/CourseUnitView.swift | 1 + .../Video/VideoPlayerViewModel.swift | 1 + Course/CourseTests/CourseMock.generated.swift | 27 +- Course/Mockfile | 3 +- Dashboard/Dashboard.xcodeproj/project.pbxproj | 24 ++ .../Dashboard/Data/DashboardRepository.swift | 1 + .../Data/Network/DashboardEndpoint.swift | 2 + .../Presentation/AllCoursesView.swift | 1 + .../Presentation/ListDashboardView.swift | 1 + .../PrimaryCourseDashboardView.swift | 1 + .../DashboardMock.generated.swift | 27 +- Dashboard/Mockfile | 3 +- Discovery/Discovery.xcodeproj/project.pbxproj | 45 +++ .../Discovery/Data/DiscoveryRepository.swift | 1 + .../Data/Network/DiscoveryEndpoint.swift | 1 + .../Presentation/DiscoveryAnalytics.swift | 1 + .../NativeDiscovery/CourseDetailsView.swift | 1 + .../NativeDiscovery/DiscoveryView.swift | 1 + .../NativeDiscovery/SearchView.swift | 1 + .../WebDiscovery/DiscoveryURIDetails.swift | 1 + .../WebDiscovery/DiscoveryWebview.swift | 1 + .../WebPrograms/ProgramWebviewView.swift | 1 + .../DiscoveryMock.generated.swift | 27 +- Discovery/Mockfile | 3 +- .../Discussion.xcodeproj/project.pbxproj | 56 ++++ .../Data/Network/DiscussionEndpoint.swift | 1 + .../Data/Network/DiscussionRepository.swift | 1 + .../Comments/Responses/ResponsesView.swift | 1 + .../Comments/Thread/ThreadView.swift | 1 + .../DiscussionSearchTopicsView.swift | 1 + .../DiscussionMock.generated.swift | 27 +- Discussion/Mockfile | 3 +- OpenEdX.xcodeproj/project.pbxproj | 197 +++--------- .../xcshareddata/swiftpm/Package.resolved | 204 ++++++++++++ OpenEdX/AppDelegate.swift | 15 +- OpenEdX/DI/AppAssembly.swift | 24 +- OpenEdX/DI/NetworkAssembly.swift | 3 +- OpenEdX/DI/ScreenAssembly.swift | 1 + OpenEdX/Data/CorePersistence.swift | 1 + .../Data/Network/NotificationsEndpoints.swift | 1 + OpenEdX/Data/ProfilePersistence.swift | 1 + .../AnalyticsManager/AnalyticsManager.swift | 40 +-- .../DeepLinkManager/Link/DeepLink.swift | 1 + .../FirebaseAnalyticsService.swift | 94 ------ .../FullStoryAnalyticsService.swift | 25 -- OpenEdX/Managers/PluginManager.swift | 20 ++ .../Listeners/BrazeListener.swift | 10 +- .../Providers/BrazeProvider.swift | 26 +- .../Providers/FCMProvider.swift | 1 + .../PushNotificationsManager.swift | 1 + .../SegmentAnalyticsService.swift | 48 --- OpenEdX/View/MainScreenViewModel.swift | 1 + Podfile | 9 +- Podfile.lock | 23 +- Profile/Mockfile | 3 +- Profile/Profile.xcodeproj/project.pbxproj | 61 ++++ .../Data/Network/ProfileEndpoint.swift | 1 + Profile/Profile/Data/ProfileRepository.swift | 1 + .../DatesAndCalendar/CalendarManager.swift | 1 + .../DatesAndCalendarViewModel.swift | 1 + .../DeleteAccount/DeleteAccountView.swift | 1 + .../EditProfile/EditProfileView.swift | 1 + .../Presentation/Profile/ProfileView.swift | 1 + .../Profile/UserProfile/UserProfileView.swift | 1 + .../Presentation/ProfileAnalytics.swift | 1 + .../Settings/ManageAccountView.swift | 1 + .../Presentation/Settings/SettingsView.swift | 1 + .../Settings/VideoQualityView.swift | 5 +- .../DeleteAccountViewModelTests.swift | 1 + .../ProfileTests/ProfileMock.generated.swift | 27 +- WhatsNew/Mockfile | 3 +- WhatsNew/WhatsNew.xcodeproj/project.pbxproj | 45 +++ .../WhatsNewMock.generated.swift | 1 + config_script/process_config.py | 9 - config_script/whitelabel.py | 34 +- fastlane/Fastfile | 3 +- 160 files changed, 910 insertions(+), 2213 deletions(-) rename {Core/Core/Providers => Authorization/Authorization}/SocialAuth/AppleAuthProvider.swift (98%) rename {Core/Core/Providers => Authorization/Authorization}/SocialAuth/Error/SocialAuthError.swift (97%) rename {Core/Core/Providers => Authorization/Authorization}/SocialAuth/FacebookAuthProvider.swift (99%) rename {Core/Core/Providers => Authorization/Authorization}/SocialAuth/GoogleAuthProvider.swift (99%) rename {Core/Core/Providers => Authorization/Authorization}/SocialAuth/MicrosoftAuthProvider.swift (99%) rename {Core/Core/Providers => Authorization/Authorization}/SocialAuth/SocialAuthResponse.swift (100%) delete mode 100644 Core/Core/Configuration/Config/FullStoryConfig.swift delete mode 100644 Core/Core/Configuration/Config/SegmentConfig.swift delete mode 100644 Core/Core/Extensions/AVPlayerViewControllerExtension.swift delete mode 100644 Core/Core/Extensions/Bundle.swift delete mode 100644 Core/Core/Extensions/CGColorExtension.swift delete mode 100644 Core/Core/Extensions/CollectionExtension.swift delete mode 100644 Core/Core/Extensions/Container+App.swift delete mode 100644 Core/Core/Extensions/DebugLog.swift delete mode 100644 Core/Core/Extensions/Dictionary+JSON.swift delete mode 100644 Core/Core/Extensions/DispatchQueue+App.swift delete mode 100644 Core/Core/Extensions/IntExtension.swift delete mode 100644 Core/Core/Extensions/RawStringExtactable.swift delete mode 100644 Core/Core/Extensions/ResultExtension.swift delete mode 100644 Core/Core/Extensions/SKStoreReviewControllerExtension.swift delete mode 100644 Core/Core/Extensions/Sequence+Extensions.swift delete mode 100644 Core/Core/Extensions/String+JSON.swift delete mode 100644 Core/Core/Extensions/StringExtension.swift delete mode 100644 Core/Core/Extensions/Thread.swift delete mode 100644 Core/Core/Extensions/UIApplicationExtension.swift delete mode 100644 Core/Core/Extensions/UINavigationController+Animation.swift delete mode 100644 Core/Core/Extensions/UIResponder+CurrentResponder.swift delete mode 100644 Core/Core/Extensions/UIView+EnclosingScrollView.swift delete mode 100644 Core/Core/Extensions/UrlExtension.swift delete mode 100644 Core/Core/Network/API.swift delete mode 100644 Core/Core/Network/Alamofire+Error.swift delete mode 100644 Core/Core/Network/EndPointType.swift delete mode 100644 Core/Core/Network/HTTPTask.swift delete mode 100644 Core/Core/Network/HeadersRedirectHandler.swift delete mode 100644 Core/Core/Network/NetworkLogger.swift delete mode 100644 Core/Core/Network/UploadBodyEncoding.swift delete mode 100644 Core/CoreTests/Configuration/FullStoryConfigTests.swift create mode 100644 OpenEdX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved delete mode 100644 OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift delete mode 100644 OpenEdX/Managers/FullStoryAnalyticsService/FullStoryAnalyticsService.swift create mode 100644 OpenEdX/Managers/PluginManager.swift delete mode 100644 OpenEdX/Managers/SegmentAnalyticsService/SegmentAnalyticsService.swift diff --git a/Authorization/Authorization.xcodeproj/project.pbxproj b/Authorization/Authorization.xcodeproj/project.pbxproj index 2a9a60060..013667223 100644 --- a/Authorization/Authorization.xcodeproj/project.pbxproj +++ b/Authorization/Authorization.xcodeproj/project.pbxproj @@ -32,6 +32,16 @@ 99C165512C0C4F7B00DC384D /* SSOWebViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 99C165502C0C4F7B00DC384D /* SSOWebViewModel.swift */; }; BA8B3A322AD5487300D25EF5 /* SocialAuthView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA8B3A312AD5487300D25EF5 /* SocialAuthView.swift */; }; BADB3F552AD6DFC3004D5CFA /* SocialAuthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADB3F542AD6DFC3004D5CFA /* SocialAuthViewModel.swift */; }; + CE7CAF2D2CC155BE00E0AC9D /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CE7CAF2C2CC155BE00E0AC9D /* OEXFoundation */; }; + CE7FB8772CC13C0B0088001A /* FacebookLogin in Frameworks */ = {isa = PBXBuildFile; productRef = CE7FB8762CC13C0B0088001A /* FacebookLogin */; }; + CE7FB87A2CC13C3C0088001A /* GoogleSignInSwift in Frameworks */ = {isa = PBXBuildFile; productRef = CE7FB8792CC13C3C0088001A /* GoogleSignInSwift */; }; + CEB1E2642CC14E3100921517 /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CEB1E2632CC14E3100921517 /* OEXFoundation */; }; + CEB25A022CC13A36007FC792 /* AppleAuthProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB259FC2CC13A36007FC792 /* AppleAuthProvider.swift */; }; + CEB25A032CC13A36007FC792 /* FacebookAuthProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB259FE2CC13A36007FC792 /* FacebookAuthProvider.swift */; }; + CEB25A042CC13A36007FC792 /* GoogleAuthProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB259FD2CC13A36007FC792 /* GoogleAuthProvider.swift */; }; + CEB25A052CC13A36007FC792 /* SocialAuthResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB25A002CC13A36007FC792 /* SocialAuthResponse.swift */; }; + CEB25A062CC13A36007FC792 /* MicrosoftAuthProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB259FF2CC13A36007FC792 /* MicrosoftAuthProvider.swift */; }; + CEB25A072CC13A36007FC792 /* SocialAuthError.swift in Sources */ = {isa = PBXBuildFile; fileRef = CEB259FA2CC13A36007FC792 /* SocialAuthError.swift */; }; DE843D6BB1B9DDA398494890 /* Pods_App_Authorization.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 47BCFB7C19382EECF15131B6 /* Pods_App_Authorization.framework */; }; E03261642AE64676002CA7EB /* StartupViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E03261632AE64676002CA7EB /* StartupViewModel.swift */; }; E03261662AE64AF4002CA7EB /* StartupView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E03261652AE64AF4002CA7EB /* StartupView.swift */; }; @@ -47,6 +57,19 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + CE7CAF2F2CC155BE00E0AC9D /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 02066B432906D72400F4307E /* SignUpView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpView.swift; sourceTree = ""; }; 02066B452906D72F00F4307E /* SignUpViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SignUpViewModel.swift; sourceTree = ""; }; @@ -88,6 +111,12 @@ A99D45203C981893C104053A /* Pods-App-Authorization-AuthorizationTests.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization-AuthorizationTests.releasestage.xcconfig"; path = "Target Support Files/Pods-App-Authorization-AuthorizationTests/Pods-App-Authorization-AuthorizationTests.releasestage.xcconfig"; sourceTree = ""; }; BA8B3A312AD5487300D25EF5 /* SocialAuthView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialAuthView.swift; sourceTree = ""; }; BADB3F542AD6DFC3004D5CFA /* SocialAuthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialAuthViewModel.swift; sourceTree = ""; }; + CEB259FA2CC13A36007FC792 /* SocialAuthError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialAuthError.swift; sourceTree = ""; }; + CEB259FC2CC13A36007FC792 /* AppleAuthProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAuthProvider.swift; sourceTree = ""; }; + CEB259FD2CC13A36007FC792 /* GoogleAuthProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleAuthProvider.swift; sourceTree = ""; }; + CEB259FE2CC13A36007FC792 /* FacebookAuthProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FacebookAuthProvider.swift; sourceTree = ""; }; + CEB259FF2CC13A36007FC792 /* MicrosoftAuthProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrosoftAuthProvider.swift; sourceTree = ""; }; + CEB25A002CC13A36007FC792 /* SocialAuthResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialAuthResponse.swift; sourceTree = ""; }; E03261632AE64676002CA7EB /* StartupViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupViewModel.swift; sourceTree = ""; }; E03261652AE64AF4002CA7EB /* StartupView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartupView.swift; sourceTree = ""; }; E78971D8E6ED2116BBF9FD66 /* Pods-App-Authorization.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Authorization.release.xcconfig"; path = "Target Support Files/Pods-App-Authorization/Pods-App-Authorization.release.xcconfig"; sourceTree = ""; }; @@ -101,6 +130,7 @@ buildActionMask = 2147483647; files = ( 07169458296D913400E3DED6 /* Authorization.framework in Frameworks */, + CE7CAF2D2CC155BE00E0AC9D /* OEXFoundation in Frameworks */, 5FB79D2802949372CDAF08D6 /* Pods_App_Authorization_AuthorizationTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -111,6 +141,9 @@ files = ( 0770DE4728D0A3DA006D8A5D /* Core.framework in Frameworks */, DE843D6BB1B9DDA398494890 /* Pods_App_Authorization.framework in Frameworks */, + CE7FB8772CC13C0B0088001A /* FacebookLogin in Frameworks */, + CEB1E2642CC14E3100921517 /* OEXFoundation in Frameworks */, + CE7FB87A2CC13C3C0088001A /* GoogleSignInSwift in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -227,6 +260,7 @@ 0770DE3D28D0A319006D8A5D /* Authorization */ = { isa = PBXGroup; children = ( + CEB25A012CC13A36007FC792 /* SocialAuth */, 0770DE6F28D0C08E006D8A5D /* SwiftGen */, 071009CC28D1E24000344290 /* Presentation */, 0770DE6D28D0C035006D8A5D /* Localizable.strings */, @@ -296,6 +330,27 @@ path = SocialAuth; sourceTree = ""; }; + CEB259FB2CC13A36007FC792 /* Error */ = { + isa = PBXGroup; + children = ( + CEB259FA2CC13A36007FC792 /* SocialAuthError.swift */, + ); + path = Error; + sourceTree = ""; + }; + CEB25A012CC13A36007FC792 /* SocialAuth */ = { + isa = PBXGroup; + children = ( + CEB259FB2CC13A36007FC792 /* Error */, + CEB259FC2CC13A36007FC792 /* AppleAuthProvider.swift */, + CEB259FD2CC13A36007FC792 /* GoogleAuthProvider.swift */, + CEB259FE2CC13A36007FC792 /* FacebookAuthProvider.swift */, + CEB259FF2CC13A36007FC792 /* MicrosoftAuthProvider.swift */, + CEB25A002CC13A36007FC792 /* SocialAuthResponse.swift */, + ); + path = SocialAuth; + sourceTree = ""; + }; E03261622AE6464A002CA7EB /* Startup */ = { isa = PBXGroup; children = ( @@ -327,6 +382,7 @@ 07169451296D913300E3DED6 /* Frameworks */, 07169452296D913300E3DED6 /* Resources */, 95C8CAF0620ABBBAD7ED66D6 /* [CP] Copy Pods Resources */, + CE7CAF2F2CC155BE00E0AC9D /* Embed Frameworks */, ); buildRules = ( ); @@ -387,6 +443,11 @@ uk, ); mainGroup = 0770DE3128D0A318006D8A5D; + packageReferences = ( + CE7FB8752CC13C0B0088001A /* XCRemoteSwiftPackageReference "facebook-ios-sdk" */, + CE7FB8782CC13C3C0088001A /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */, + CEB1E2622CC14E3100921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */, + ); productRefGroup = 0770DE3C28D0A319006D8A5D /* Products */; projectDirPath = ""; projectRoot = ""; @@ -532,6 +593,12 @@ 071009C728D1DA4F00344290 /* SignInViewModel.swift in Sources */, BA8B3A322AD5487300D25EF5 /* SocialAuthView.swift in Sources */, 99C1654D2C0C4F2F00DC384D /* SSOHelper.swift in Sources */, + CEB25A022CC13A36007FC792 /* AppleAuthProvider.swift in Sources */, + CEB25A032CC13A36007FC792 /* FacebookAuthProvider.swift in Sources */, + CEB25A042CC13A36007FC792 /* GoogleAuthProvider.swift in Sources */, + CEB25A052CC13A36007FC792 /* SocialAuthResponse.swift in Sources */, + CEB25A062CC13A36007FC792 /* MicrosoftAuthProvider.swift in Sources */, + CEB25A072CC13A36007FC792 /* SocialAuthError.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -1515,6 +1582,56 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + CE7FB8752CC13C0B0088001A /* XCRemoteSwiftPackageReference "facebook-ios-sdk" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/facebook/facebook-ios-sdk"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 16.3.1; + }; + }; + CE7FB8782CC13C3C0088001A /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/google/GoogleSignIn-iOS"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 8.0.0; + }; + }; + CEB1E2622CC14E3100921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/openedx/openedx-app-foundation-ios/"; + requirement = { + kind = exactVersion; + version = 1.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + CE7CAF2C2CC155BE00E0AC9D /* OEXFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = CEB1E2622CC14E3100921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */; + productName = OEXFoundation; + }; + CE7FB8762CC13C0B0088001A /* FacebookLogin */ = { + isa = XCSwiftPackageProductDependency; + package = CE7FB8752CC13C0B0088001A /* XCRemoteSwiftPackageReference "facebook-ios-sdk" */; + productName = FacebookLogin; + }; + CE7FB8792CC13C3C0088001A /* GoogleSignInSwift */ = { + isa = XCSwiftPackageProductDependency; + package = CE7FB8782CC13C3C0088001A /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */; + productName = GoogleSignInSwift; + }; + CEB1E2632CC14E3100921517 /* OEXFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = CEB1E2622CC14E3100921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */; + productName = OEXFoundation; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 0770DE3228D0A318006D8A5D /* Project object */; } diff --git a/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift b/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift index 9371a1b40..240f60fa0 100644 --- a/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift +++ b/Authorization/Authorization/Presentation/AuthorizationAnalytics.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation public enum AuthMethod: Equatable { case password diff --git a/Authorization/Authorization/Presentation/Login/SignInView.swift b/Authorization/Authorization/Presentation/Login/SignInView.swift index 104b506cd..84d14adb0 100644 --- a/Authorization/Authorization/Presentation/Login/SignInView.swift +++ b/Authorization/Authorization/Presentation/Login/SignInView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import OEXFoundation import Theme import Swinject diff --git a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift index 22040cd4e..b719a9ede 100644 --- a/Authorization/Authorization/Presentation/Login/SignInViewModel.swift +++ b/Authorization/Authorization/Presentation/Login/SignInViewModel.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation import SwiftUI import Alamofire import AuthenticationServices diff --git a/Authorization/Authorization/Presentation/Registration/SignUpView.swift b/Authorization/Authorization/Presentation/Registration/SignUpView.swift index a4a869383..c4d46caf2 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpView.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import OEXFoundation import Theme public struct SignUpView: View { diff --git a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift index 8b2fe1b22..a53403f9d 100644 --- a/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift +++ b/Authorization/Authorization/Presentation/Registration/SignUpViewModel.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation import SwiftUI import AuthenticationServices import FacebookLogin diff --git a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift index 2a993a7eb..e069b75e1 100644 --- a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift +++ b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import OEXFoundation import Theme public struct ResetPasswordView: View { diff --git a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordViewModel.swift b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordViewModel.swift index 10b2edc00..8310e2e64 100644 --- a/Authorization/Authorization/Presentation/Reset Password/ResetPasswordViewModel.swift +++ b/Authorization/Authorization/Presentation/Reset Password/ResetPasswordViewModel.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import OEXFoundation public class ResetPasswordViewModel: ObservableObject { diff --git a/Authorization/Authorization/Presentation/SSO/SSOWebViewModel.swift b/Authorization/Authorization/Presentation/SSO/SSOWebViewModel.swift index 043a34060..07e0faa55 100644 --- a/Authorization/Authorization/Presentation/SSO/SSOWebViewModel.swift +++ b/Authorization/Authorization/Presentation/SSO/SSOWebViewModel.swift @@ -7,6 +7,7 @@ import Foundation import SwiftUI +import OEXFoundation import Core import Alamofire import AuthenticationServices diff --git a/Core/Core/Providers/SocialAuth/AppleAuthProvider.swift b/Authorization/Authorization/SocialAuth/AppleAuthProvider.swift similarity index 98% rename from Core/Core/Providers/SocialAuth/AppleAuthProvider.swift rename to Authorization/Authorization/SocialAuth/AppleAuthProvider.swift index 1ed3d4c06..27afa234a 100644 --- a/Core/Core/Providers/SocialAuth/AppleAuthProvider.swift +++ b/Authorization/Authorization/SocialAuth/AppleAuthProvider.swift @@ -8,6 +8,8 @@ import Foundation import AuthenticationServices import Swinject +import OEXFoundation +import Core public final class AppleAuthProvider: NSObject, ASAuthorizationControllerDelegate { diff --git a/Core/Core/Providers/SocialAuth/Error/SocialAuthError.swift b/Authorization/Authorization/SocialAuth/Error/SocialAuthError.swift similarity index 97% rename from Core/Core/Providers/SocialAuth/Error/SocialAuthError.swift rename to Authorization/Authorization/SocialAuth/Error/SocialAuthError.swift index a9167451e..27071bab7 100644 --- a/Core/Core/Providers/SocialAuth/Error/SocialAuthError.swift +++ b/Authorization/Authorization/SocialAuth/Error/SocialAuthError.swift @@ -6,6 +6,7 @@ // import Foundation +import Core public enum SocialAuthError: Error { case error(text: String) diff --git a/Core/Core/Providers/SocialAuth/FacebookAuthProvider.swift b/Authorization/Authorization/SocialAuth/FacebookAuthProvider.swift similarity index 99% rename from Core/Core/Providers/SocialAuth/FacebookAuthProvider.swift rename to Authorization/Authorization/SocialAuth/FacebookAuthProvider.swift index 66ae46b6d..b5913bb25 100644 --- a/Core/Core/Providers/SocialAuth/FacebookAuthProvider.swift +++ b/Authorization/Authorization/SocialAuth/FacebookAuthProvider.swift @@ -7,6 +7,7 @@ import Foundation import FacebookLogin +import Core public final class FacebookAuthProvider { diff --git a/Core/Core/Providers/SocialAuth/GoogleAuthProvider.swift b/Authorization/Authorization/SocialAuth/GoogleAuthProvider.swift similarity index 99% rename from Core/Core/Providers/SocialAuth/GoogleAuthProvider.swift rename to Authorization/Authorization/SocialAuth/GoogleAuthProvider.swift index c600c1735..fb4336af2 100644 --- a/Core/Core/Providers/SocialAuth/GoogleAuthProvider.swift +++ b/Authorization/Authorization/SocialAuth/GoogleAuthProvider.swift @@ -7,6 +7,7 @@ import GoogleSignIn import Foundation +import Core public final class GoogleAuthProvider { diff --git a/Core/Core/Providers/SocialAuth/MicrosoftAuthProvider.swift b/Authorization/Authorization/SocialAuth/MicrosoftAuthProvider.swift similarity index 99% rename from Core/Core/Providers/SocialAuth/MicrosoftAuthProvider.swift rename to Authorization/Authorization/SocialAuth/MicrosoftAuthProvider.swift index 2fd998579..813c755f1 100644 --- a/Core/Core/Providers/SocialAuth/MicrosoftAuthProvider.swift +++ b/Authorization/Authorization/SocialAuth/MicrosoftAuthProvider.swift @@ -8,6 +8,8 @@ import Foundation import MSAL import Swinject +import OEXFoundation +import Core public typealias MSLoginCompletionHandler = (account: MSALAccount, token: String) diff --git a/Core/Core/Providers/SocialAuth/SocialAuthResponse.swift b/Authorization/Authorization/SocialAuth/SocialAuthResponse.swift similarity index 100% rename from Core/Core/Providers/SocialAuth/SocialAuthResponse.swift rename to Authorization/Authorization/SocialAuth/SocialAuthResponse.swift diff --git a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift index 59adf71ce..31285ffcb 100644 --- a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift +++ b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift @@ -13,6 +13,7 @@ import Authorization import Foundation import SwiftUI import Combine +import OEXFoundation // MARK: - AuthInteractorProtocol diff --git a/Authorization/AuthorizationTests/Presentation/Login/ResetPasswordViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/Login/ResetPasswordViewModelTests.swift index 6b09633f2..e5ffa55e7 100644 --- a/Authorization/AuthorizationTests/Presentation/Login/ResetPasswordViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/Login/ResetPasswordViewModelTests.swift @@ -9,6 +9,7 @@ import SwiftyMocky import XCTest @testable import Core @testable import Authorization +import OEXFoundation import Alamofire import SwiftUI diff --git a/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift index 1435a61b9..c91d48adc 100644 --- a/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/Login/SignInViewModelTests.swift @@ -9,6 +9,7 @@ import SwiftyMocky import XCTest @testable import Core @testable import Authorization +import OEXFoundation import Alamofire import SwiftUI diff --git a/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift b/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift index ad180a925..7e8236d27 100644 --- a/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift +++ b/Authorization/AuthorizationTests/Presentation/Register/SignUpViewModelTests.swift @@ -9,6 +9,7 @@ import SwiftyMocky import XCTest @testable import Core @testable import Authorization +import OEXFoundation import Alamofire import SwiftUI diff --git a/Authorization/Mockfile b/Authorization/Mockfile index 5e0805e28..a3ae4b4f7 100644 --- a/Authorization/Mockfile +++ b/Authorization/Mockfile @@ -14,4 +14,5 @@ unit.tests.mock: - Authorization - Foundation - SwiftUI - - Combine \ No newline at end of file + - Combine + - OEXFoundation \ No newline at end of file diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index 3ba4ab39b..d1a063a0e 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -15,9 +15,7 @@ 021D925028DC89D100ACC565 /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D924F28DC89D100ACC565 /* UserProfile.swift */; }; 021D925728DCF12900ACC565 /* AlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 021D925628DCF12900ACC565 /* AlertView.swift */; }; 022020462C11BB2200D15795 /* Data_CourseDates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022020452C11BB2200D15795 /* Data_CourseDates.swift */; }; - 02228B312C2232D2009A5F28 /* IntExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02228B302C2232D2009A5F28 /* IntExtension.swift */; }; 02280F5B294B4E6F0032823A /* Connectivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02280F5A294B4E6F0032823A /* Connectivity.swift */; }; - 02284C182A3B1AE00007117F /* UIApplicationExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02284C172A3B1AE00007117F /* UIApplicationExtension.swift */; }; 02286D162C106393005EEC8D /* CourseDates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02286D152C106393005EEC8D /* CourseDates.swift */; }; 022C64E429AE0191000F532B /* TextWithUrls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 022C64E329AE0191000F532B /* TextWithUrls.swift */; }; 0231CDBE2922422D00032416 /* CSSInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0231CDBD2922422D00032416 /* CSSInjector.swift */; }; @@ -34,16 +32,13 @@ 023A4DD4299E66BD006C0E48 /* OfflineSnackBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023A4DD3299E66BD006C0E48 /* OfflineSnackBarView.swift */; }; 0241666B28F5A78B00082765 /* HTMLFormattedText.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0241666A28F5A78B00082765 /* HTMLFormattedText.swift */; }; 0248C92329C075EF00DC8402 /* CourseBlockModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0248C92229C075EF00DC8402 /* CourseBlockModel.swift */; }; - 024BE3DF29B2615500BCDEE2 /* CGColorExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024BE3DE29B2615500BCDEE2 /* CGColorExtension.swift */; }; 024D723529C8BB1A006D36ED /* NavigationBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024D723429C8BB1A006D36ED /* NavigationBar.swift */; }; 024FCD0028EF1CD300232339 /* WebBrowser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 024FCCFF28EF1CD300232339 /* WebBrowser.swift */; }; 02512FF0299533DF0024D438 /* CoreDataHandlerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02512FEF299533DE0024D438 /* CoreDataHandlerProtocol.swift */; }; 0254D1912BCD699F000CDE89 /* RefreshProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0254D1902BCD699F000CDE89 /* RefreshProgressView.swift */; }; - 0255D5582936283A004DBC1A /* UploadBodyEncoding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0255D55729362839004DBC1A /* UploadBodyEncoding.swift */; }; 0259104A29C4A5B6004B5A55 /* UserSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0259104929C4A5B6004B5A55 /* UserSettings.swift */; }; 025A20502C071EB0003EA08D /* ErrorAlertView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025A204F2C071EB0003EA08D /* ErrorAlertView.swift */; }; 025B36752A13B7D5001A640E /* UnitButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 025B36742A13B7D5001A640E /* UnitButtonView.swift */; }; - 025EF2F62971740000B838AB /* YouTubePlayerKit in Frameworks */ = {isa = PBXBuildFile; productRef = 025EF2F52971740000B838AB /* YouTubePlayerKit */; }; 0260E58028FD792800BBBE18 /* WebUnitViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0260E57F28FD792800BBBE18 /* WebUnitViewModel.swift */; }; 0267F8512C3C256F0089D810 /* FileWebView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0267F8502C3C256F0089D810 /* FileWebView.swift */; }; 027BD3922907D88F00392132 /* Data_RegistrationFields.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3912907D88F00392132 /* Data_RegistrationFields.swift */; }; @@ -58,8 +53,6 @@ 027BD3B52909475900392132 /* KeyboardStateObserver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3B22909475900392132 /* KeyboardStateObserver.swift */; }; 027BD3B82909476200392132 /* DismissKeyboardTapViewModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3B62909476200392132 /* DismissKeyboardTapViewModifier.swift */; }; 027BD3B92909476200392132 /* KeyboardAvoidingModifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3B72909476200392132 /* KeyboardAvoidingModifier.swift */; }; - 027BD3BD2909478B00392132 /* UIView+EnclosingScrollView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3BA2909478B00392132 /* UIView+EnclosingScrollView.swift */; }; - 027BD3BE2909478B00392132 /* UIResponder+CurrentResponder.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3BB2909478B00392132 /* UIResponder+CurrentResponder.swift */; }; 027BD3C52909707700392132 /* Shake.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027BD3C42909707700392132 /* Shake.swift */; }; 027F1BF72C071C820001A24C /* NavigationTitle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 027F1BF62C071C820001A24C /* NavigationTitle.swift */; }; 0282DA7328F98CC9003C3F07 /* WebUnitView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0282DA7228F98CC9003C3F07 /* WebUnitView.swift */; }; @@ -76,7 +69,6 @@ 029A132A2C2471DF005FB830 /* OfflineSyncRepository.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029A13292C2471DF005FB830 /* OfflineSyncRepository.swift */; }; 029A132C2C2471F8005FB830 /* OfflineSyncEndpoint.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029A132B2C2471F8005FB830 /* OfflineSyncEndpoint.swift */; }; 029A13302C2479E7005FB830 /* OfflineSyncInteractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029A132F2C2479E7005FB830 /* OfflineSyncInteractor.swift */; }; - 029EE3ED2BF6650500F64F33 /* Bundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 029EE3EC2BF6650500F64F33 /* Bundle.swift */; }; 02A463112AEA966C00331037 /* AppReviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A463102AEA966C00331037 /* AppReviewView.swift */; }; 02A4833529B8A73400D33F33 /* CorePersistenceProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833429B8A73400D33F33 /* CorePersistenceProtocol.swift */; }; 02A4833829B8A8F900D33F33 /* CoreDataModel.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 02A4833629B8A8F800D33F33 /* CoreDataModel.xcdatamodeld */; }; @@ -86,26 +78,18 @@ 02AA27942C2C1B88006F5B6A /* ZipArchive in Frameworks */ = {isa = PBXBuildFile; productRef = 02AA27932C2C1B88006F5B6A /* ZipArchive */; }; 02AFCC182AEFDB24000360F0 /* ThirdPartyMailClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AFCC172AEFDB24000360F0 /* ThirdPartyMailClient.swift */; }; 02AFCC1A2AEFDC18000360F0 /* ThirdPartyMailer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02AFCC192AEFDC18000360F0 /* ThirdPartyMailer.swift */; }; - 02B2B594295C5C7A00914876 /* Thread.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B2B593295C5C7A00914876 /* Thread.swift */; }; - 02B3E3B32930198600A50475 /* AVPlayerViewControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02B3E3B22930198600A50475 /* AVPlayerViewControllerExtension.swift */; }; 02C917F029CDA99E00DBB8BD /* Data_Enrollments.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02C917EF29CDA99E00DBB8BD /* Data_Enrollments.swift */; }; 02CA59842BD7DDBE00D517AA /* DashboardConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CA59832BD7DDBE00D517AA /* DashboardConfig.swift */; }; 02CF46C829546AA200A698EE /* NoCachedDataError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02CF46C729546AA200A698EE /* NoCachedDataError.swift */; }; - 02D400612B0678190029D168 /* SKStoreReviewControllerExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D400602B0678190029D168 /* SKStoreReviewControllerExtension.swift */; }; 02D800CC29348F460099CF16 /* ImagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02D800CB29348F460099CF16 /* ImagePicker.swift */; }; 02E224DB2BB76B3E00EF1ADB /* DynamicOffsetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E224DA2BB76B3E00EF1ADB /* DynamicOffsetView.swift */; }; - 02E225B0291D29EB0067769A /* UrlExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E225AF291D29EB0067769A /* UrlExtension.swift */; }; 02E93F852AEBAEBC006C4750 /* AppReviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02E93F842AEBAEBC006C4750 /* AppReviewViewModel.swift */; }; 02EBC7572C19DCDB00BE182C /* SyncStatus.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EBC7562C19DCDB00BE182C /* SyncStatus.swift */; }; 02EBC7592C19DE1100BE182C /* CalendarManagerProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EBC7582C19DE1100BE182C /* CalendarManagerProtocol.swift */; }; 02EBC75B2C19DE3D00BE182C /* CourseForSync.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02EBC75A2C19DE3D00BE182C /* CourseForSync.swift */; }; - 02F164372902A9EB0090DDEF /* StringExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F164362902A9EB0090DDEF /* StringExtension.swift */; }; 02F6EF3B28D9B8EC00835477 /* CourseCellView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F6EF3A28D9B8EC00835477 /* CourseCellView.swift */; }; 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 */; }; @@ -129,67 +113,45 @@ 0727877028D23411002E9142 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0727876F28D23411002E9142 /* Config.swift */; }; 0727877728D23847002E9142 /* DataLayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0727877628D23847002E9142 /* DataLayer.swift */; }; 0727877928D23BE0002E9142 /* RequestInterceptor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0727877828D23BE0002E9142 /* RequestInterceptor.swift */; }; - 0727877B28D24A1D002E9142 /* HeadersRedirectHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0727877A28D24A1D002E9142 /* HeadersRedirectHandler.swift */; }; 0727877D28D25212002E9142 /* ProgressBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0727877C28D25212002E9142 /* ProgressBar.swift */; }; - 0727877F28D25B24002E9142 /* Alamofire+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0727877E28D25B24002E9142 /* Alamofire+Error.swift */; }; 0727878128D25EFD002E9142 /* SnackBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0727878028D25EFD002E9142 /* SnackBarView.swift */; }; - 0727878328D31287002E9142 /* DispatchQueue+App.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0727878228D31287002E9142 /* DispatchQueue+App.swift */; }; 0727878528D31657002E9142 /* Data_User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0727878428D31657002E9142 /* Data_User.swift */; }; 0727878928D31734002E9142 /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0727878828D31734002E9142 /* User.swift */; }; 072787B628D37A0E002E9142 /* Validator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 072787B528D37A0E002E9142 /* Validator.swift */; }; - 07460FE1294B706200F70538 /* CollectionExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07460FE0294B706200F70538 /* CollectionExtension.swift */; }; 07460FE3294B72D700F70538 /* Notification.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07460FE2294B72D700F70538 /* Notification.swift */; }; 076F297F2A1F80C800967E7D /* Pagination.swift in Sources */ = {isa = PBXBuildFile; fileRef = 076F297E2A1F80C800967E7D /* Pagination.swift */; }; 0770DE1928D0847D006D8A5D /* BaseRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE1828D0847D006D8A5D /* BaseRouter.swift */; }; 0770DE2528D08FBA006D8A5D /* CoreStorage.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE2428D08FBA006D8A5D /* CoreStorage.swift */; }; - 0770DE2A28D0929E006D8A5D /* HTTPTask.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE2928D0929E006D8A5D /* HTTPTask.swift */; }; - 0770DE2C28D092B3006D8A5D /* NetworkLogger.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE2B28D092B3006D8A5D /* NetworkLogger.swift */; }; - 0770DE2E28D09743006D8A5D /* API.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE2D28D09743006D8A5D /* API.swift */; }; - 0770DE3028D09793006D8A5D /* EndPointType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE2F28D09793006D8A5D /* EndPointType.swift */; }; 0770DE5228D0ADFF006D8A5D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0770DE5128D0ADFF006D8A5D /* Assets.xcassets */; }; 0770DE5428D0B00C006D8A5D /* swiftgen.yml in Resources */ = {isa = PBXBuildFile; fileRef = 0770DE5328D0B00C006D8A5D /* swiftgen.yml */; }; 0770DE5B28D0B209006D8A5D /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0770DE5D28D0B209006D8A5D /* Localizable.strings */; }; 0770DE5F28D0B22C006D8A5D /* Strings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE5E28D0B22C006D8A5D /* Strings.swift */; }; 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 */; }; - 142EDD6C2B831D1400F9F320 /* BranchSDK in Frameworks */ = {isa = PBXBuildFile; productRef = 142EDD6B2B831D1400F9F320 /* BranchSDK */; }; 14769D3C2B9822EE00AB36D4 /* CoreAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14769D3B2B9822EE00AB36D4 /* CoreAnalytics.swift */; }; - 14D912D92C2553C70077CCCE /* FullStoryConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D912D82C2553C70077CCCE /* FullStoryConfig.swift */; }; - 14D912DB2C257E9E0077CCCE /* FullStoryConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D912DA2C257E9E0077CCCE /* FullStoryConfigTests.swift */; }; 5E58740A2AA9DF20F4644191 /* Pods_App_Core_CoreTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 33FA09A20AAE2B2A0BA89190 /* Pods_App_Core_CoreTests.framework */; }; 9784D47E2BF7762800AFEFFF /* FullScreenErrorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9784D47D2BF7762800AFEFFF /* FullScreenErrorView.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 */; }; - BA30427F2B20B320009B64B7 /* SocialAuthError.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA30427D2B20B299009B64B7 /* SocialAuthError.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 */; }; - BA8B3A2F2AD546A700D25EF5 /* DebugLog.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA8B3A2E2AD546A700D25EF5 /* DebugLog.swift */; }; - BA8FA6612AD5974300EA029A /* AppleAuthProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA8FA6602AD5974300EA029A /* AppleAuthProvider.swift */; }; BA8FA6682AD59A5700EA029A /* SocialAuthButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA8FA6672AD59A5700EA029A /* SocialAuthButton.swift */; }; - BA8FA66A2AD59B5500EA029A /* GoogleAuthProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA8FA6692AD59B5500EA029A /* GoogleAuthProvider.swift */; }; - 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 */; }; - BA981BCE2B8F5C49005707C2 /* Sequence+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA981BCD2B8F5C49005707C2 /* Sequence+Extensions.swift */; }; BA981BD02B91ED50005707C2 /* FullScreenProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA981BCF2B91ED50005707C2 /* FullScreenProgressView.swift */; }; BAD9CA2F2B289B3500DE790A /* ajaxHandler.js in Resources */ = {isa = PBXBuildFile; fileRef = BAD9CA2E2B289B3500DE790A /* ajaxHandler.js */; }; BAD9CA332B28A8F300DE790A /* AjaxProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA322B28A8F300DE790A /* AjaxProvider.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 */; }; BAFB99822B0E2354007D09F9 /* FacebookConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAFB99812B0E2354007D09F9 /* FacebookConfig.swift */; }; BAFB99842B0E282E007D09F9 /* MicrosoftConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAFB99832B0E282E007D09F9 /* MicrosoftConfig.swift */; }; BAFB99902B14B377007D09F9 /* GoogleConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAFB998F2B14B377007D09F9 /* GoogleConfig.swift */; }; BAFB99922B14E23D007D09F9 /* AppleSignInConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAFB99912B14E23D007D09F9 /* AppleSignInConfig.swift */; }; C8C446EF233F81B9FABB77D2 /* Pods_App_Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 349B90CD6579F7B8D257E515 /* Pods_App_Core.framework */; }; CE54C2D22CC80D8500E529F9 /* DownloadManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE54C2D12CC80D8500E529F9 /* DownloadManagerTests.swift */; }; + CE57127C2CD109DB00D4AB17 /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CE57127B2CD109DB00D4AB17 /* OEXFoundation */; }; + CE7CAF392CC1561E00E0AC9D /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CE7CAF382CC1561E00E0AC9D /* OEXFoundation */; }; CE953A3B2CD0DA940023D667 /* CoreMock.generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE953A3A2CD0DA940023D667 /* CoreMock.generated.swift */; }; CFC84952299F8B890055E497 /* Debounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFC84951299F8B890055E497 /* Debounce.swift */; }; DBF6F2412B014ADA0098414B /* FirebaseConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */; }; @@ -198,7 +160,6 @@ E055A5392B18DC95008D9E5E /* Theme.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E055A5382B18DC95008D9E5E /* Theme.framework */; }; E09179FD2B0F204E002AB695 /* ConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E09179FC2B0F204D002AB695 /* ConfigTests.swift */; }; E0D5861A2B2FF74C009B4BA7 /* DiscoveryConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D586192B2FF74C009B4BA7 /* DiscoveryConfig.swift */; }; - E0D5861C2B2FF85B009B4BA7 /* RawStringExtactable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D5861B2B2FF85B009B4BA7 /* RawStringExtactable.swift */; }; E0D586362B314CD3009B4BA7 /* LogistrationBottomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D586352B314CD3009B4BA7 /* LogistrationBottomView.swift */; }; /* End PBXBuildFile section */ @@ -212,6 +173,29 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + CE57127E2CD109DB00D4AB17 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + CE7CAF3B2CC1561E00E0AC9D /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 020306CB2932C0C4000949EA /* PickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickerView.swift; sourceTree = ""; }; 02066B472906F73400F4307E /* PickerMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PickerMenu.swift; sourceTree = ""; }; @@ -221,9 +205,7 @@ 021D924F28DC89D100ACC565 /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; 021D925628DCF12900ACC565 /* AlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertView.swift; sourceTree = ""; }; 022020452C11BB2200D15795 /* Data_CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_CourseDates.swift; sourceTree = ""; }; - 02228B302C2232D2009A5F28 /* IntExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IntExtension.swift; sourceTree = ""; }; 02280F5A294B4E6F0032823A /* Connectivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Connectivity.swift; sourceTree = ""; }; - 02284C172A3B1AE00007117F /* UIApplicationExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UIApplicationExtension.swift; sourceTree = ""; }; 02286D152C106393005EEC8D /* CourseDates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseDates.swift; sourceTree = ""; }; 022C64E329AE0191000F532B /* TextWithUrls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextWithUrls.swift; sourceTree = ""; }; 0231CDBD2922422D00032416 /* CSSInjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CSSInjector.swift; sourceTree = ""; }; @@ -240,12 +222,10 @@ 023A4DD3299E66BD006C0E48 /* OfflineSnackBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineSnackBarView.swift; sourceTree = ""; }; 0241666A28F5A78B00082765 /* HTMLFormattedText.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTMLFormattedText.swift; sourceTree = ""; }; 0248C92229C075EF00DC8402 /* CourseBlockModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseBlockModel.swift; sourceTree = ""; }; - 024BE3DE29B2615500BCDEE2 /* CGColorExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGColorExtension.swift; sourceTree = ""; }; 024D723429C8BB1A006D36ED /* NavigationBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBar.swift; sourceTree = ""; }; 024FCCFF28EF1CD300232339 /* WebBrowser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebBrowser.swift; sourceTree = ""; }; 02512FEF299533DE0024D438 /* CoreDataHandlerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreDataHandlerProtocol.swift; sourceTree = ""; }; 0254D1902BCD699F000CDE89 /* RefreshProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RefreshProgressView.swift; sourceTree = ""; }; - 0255D55729362839004DBC1A /* UploadBodyEncoding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadBodyEncoding.swift; sourceTree = ""; }; 0259104929C4A5B6004B5A55 /* UserSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettings.swift; sourceTree = ""; }; 025A204F2C071EB0003EA08D /* ErrorAlertView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ErrorAlertView.swift; sourceTree = ""; }; 025B36742A13B7D5001A640E /* UnitButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UnitButtonView.swift; sourceTree = ""; }; @@ -263,8 +243,6 @@ 027BD3B22909475900392132 /* KeyboardStateObserver.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyboardStateObserver.swift; sourceTree = ""; }; 027BD3B62909476200392132 /* DismissKeyboardTapViewModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DismissKeyboardTapViewModifier.swift; sourceTree = ""; }; 027BD3B72909476200392132 /* KeyboardAvoidingModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = KeyboardAvoidingModifier.swift; sourceTree = ""; }; - 027BD3BA2909478B00392132 /* UIView+EnclosingScrollView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIView+EnclosingScrollView.swift"; sourceTree = ""; }; - 027BD3BB2909478B00392132 /* UIResponder+CurrentResponder.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "UIResponder+CurrentResponder.swift"; sourceTree = ""; }; 027BD3C42909707700392132 /* Shake.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shake.swift; sourceTree = ""; }; 027F1BF62C071C820001A24C /* NavigationTitle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationTitle.swift; sourceTree = ""; }; 0282DA7228F98CC9003C3F07 /* WebUnitView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebUnitView.swift; sourceTree = ""; }; @@ -281,7 +259,6 @@ 029A13292C2471DF005FB830 /* OfflineSyncRepository.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineSyncRepository.swift; sourceTree = ""; }; 029A132B2C2471F8005FB830 /* OfflineSyncEndpoint.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineSyncEndpoint.swift; sourceTree = ""; }; 029A132F2C2479E7005FB830 /* OfflineSyncInteractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineSyncInteractor.swift; sourceTree = ""; }; - 029EE3EC2BF6650500F64F33 /* Bundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Bundle.swift; sourceTree = ""; }; 02A463102AEA966C00331037 /* AppReviewView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReviewView.swift; sourceTree = ""; }; 02A4833429B8A73400D33F33 /* CorePersistenceProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CorePersistenceProtocol.swift; sourceTree = ""; }; 02A4833729B8A8F800D33F33 /* CoreDataModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CoreDataModel.xcdatamodel; sourceTree = ""; }; @@ -290,27 +267,19 @@ 02A8C5802C05DBB4004B91FF /* Data_EnrollmentsStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_EnrollmentsStatus.swift; sourceTree = ""; }; 02AFCC172AEFDB24000360F0 /* ThirdPartyMailClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThirdPartyMailClient.swift; sourceTree = ""; }; 02AFCC192AEFDC18000360F0 /* ThirdPartyMailer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThirdPartyMailer.swift; sourceTree = ""; }; - 02B2B593295C5C7A00914876 /* Thread.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Thread.swift; sourceTree = ""; }; - 02B3E3B22930198600A50475 /* AVPlayerViewControllerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AVPlayerViewControllerExtension.swift; sourceTree = ""; }; 02C917EF29CDA99E00DBB8BD /* Data_Enrollments.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_Enrollments.swift; sourceTree = ""; }; 02CA59832BD7DDBE00D517AA /* DashboardConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardConfig.swift; sourceTree = ""; }; 02CF46C729546AA200A698EE /* NoCachedDataError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoCachedDataError.swift; sourceTree = ""; }; - 02D400602B0678190029D168 /* SKStoreReviewControllerExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SKStoreReviewControllerExtension.swift; sourceTree = ""; }; 02D800CB29348F460099CF16 /* ImagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImagePicker.swift; sourceTree = ""; }; 02E224DA2BB76B3E00EF1ADB /* DynamicOffsetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicOffsetView.swift; sourceTree = ""; }; - 02E225AF291D29EB0067769A /* UrlExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UrlExtension.swift; sourceTree = ""; }; 02E93F842AEBAEBC006C4750 /* AppReviewViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppReviewViewModel.swift; sourceTree = ""; }; 02EBC7562C19DCDB00BE182C /* SyncStatus.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncStatus.swift; sourceTree = ""; }; 02EBC7582C19DE1100BE182C /* CalendarManagerProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalendarManagerProtocol.swift; sourceTree = ""; }; 02EBC75A2C19DE3D00BE182C /* CourseForSync.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseForSync.swift; sourceTree = ""; }; - 02F164362902A9EB0090DDEF /* StringExtension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = StringExtension.swift; sourceTree = ""; }; 02F6EF3A28D9B8EC00835477 /* CourseCellView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CourseCellView.swift; sourceTree = ""; }; 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 = ""; }; 043DD0B526F919DFA1C5E600 /* Pods-App-Core-CoreTests.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core-CoreTests.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests.releaseprod.xcconfig"; 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 = ""; }; @@ -334,38 +303,27 @@ 0727876F28D23411002E9142 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; 0727877628D23847002E9142 /* DataLayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataLayer.swift; sourceTree = ""; }; 0727877828D23BE0002E9142 /* RequestInterceptor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestInterceptor.swift; sourceTree = ""; }; - 0727877A28D24A1D002E9142 /* HeadersRedirectHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HeadersRedirectHandler.swift; sourceTree = ""; }; 0727877C28D25212002E9142 /* ProgressBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgressBar.swift; sourceTree = ""; }; - 0727877E28D25B24002E9142 /* Alamofire+Error.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Alamofire+Error.swift"; sourceTree = ""; }; 0727878028D25EFD002E9142 /* SnackBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SnackBarView.swift; sourceTree = ""; }; - 0727878228D31287002E9142 /* DispatchQueue+App.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DispatchQueue+App.swift"; sourceTree = ""; }; 0727878428D31657002E9142 /* Data_User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_User.swift; sourceTree = ""; }; 0727878828D31734002E9142 /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; 072787B528D37A0E002E9142 /* Validator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Validator.swift; sourceTree = ""; }; - 07460FE0294B706200F70538 /* CollectionExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionExtension.swift; sourceTree = ""; }; 07460FE2294B72D700F70538 /* Notification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notification.swift; sourceTree = ""; }; 0754BB7841E3C0F8D6464951 /* Pods-App-Core.releasestage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core.releasestage.xcconfig"; path = "Target Support Files/Pods-App-Core/Pods-App-Core.releasestage.xcconfig"; sourceTree = ""; }; 076F297E2A1F80C800967E7D /* Pagination.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Pagination.swift; sourceTree = ""; }; 0770DE0828D07831006D8A5D /* Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0770DE1828D0847D006D8A5D /* BaseRouter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BaseRouter.swift; sourceTree = ""; }; 0770DE2428D08FBA006D8A5D /* CoreStorage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoreStorage.swift; sourceTree = ""; }; - 0770DE2928D0929E006D8A5D /* HTTPTask.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPTask.swift; sourceTree = ""; }; - 0770DE2B28D092B3006D8A5D /* NetworkLogger.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetworkLogger.swift; sourceTree = ""; }; - 0770DE2D28D09743006D8A5D /* API.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = API.swift; sourceTree = ""; }; - 0770DE2F28D09793006D8A5D /* EndPointType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EndPointType.swift; sourceTree = ""; }; 0770DE5128D0ADFF006D8A5D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 0770DE5328D0B00C006D8A5D /* swiftgen.yml */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.yaml; path = swiftgen.yml; sourceTree = ""; }; 0770DE5C28D0B209006D8A5D /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Localizable.strings; sourceTree = ""; }; 0770DE5E28D0B22C006D8A5D /* Strings.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; 0770DE6028D0B2CB006D8A5D /* Assets.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Assets.swift; sourceTree = ""; }; - 07DDFCBC29A780BB00572595 /* UINavigationController+Animation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UINavigationController+Animation.swift"; sourceTree = ""; }; 07E0939E2B308D2800F1E4B2 /* Data_Certificate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Data_Certificate.swift; sourceTree = ""; }; 0CA4A65A3AECED83CC425A00 /* Pods-CoreTests.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CoreTests.debugstage.xcconfig"; path = "Target Support Files/Pods-CoreTests/Pods-CoreTests.debugstage.xcconfig"; 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 = ""; }; - 14D912D82C2553C70077CCCE /* FullStoryConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullStoryConfig.swift; sourceTree = ""; }; - 14D912DA2C257E9E0077CCCE /* FullStoryConfigTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullStoryConfigTests.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 = ""; }; 33FA09A20AAE2B2A0BA89190 /* Pods_App_Core_CoreTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_Core_CoreTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -380,29 +338,19 @@ 9784D47D2BF7762800AFEFFF /* FullScreenErrorView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FullScreenErrorView.swift; 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 = ""; }; 9E0B33614CBD791307FFDEAE /* Pods-App-Core-CoreTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core-CoreTests.release.xcconfig"; path = "Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests.release.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 = ""; }; B2556B4A2D4F84F402A7A7D9 /* Pods-CoreTests.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CoreTests.releaseprod.xcconfig"; path = "Target Support Files/Pods-CoreTests/Pods-CoreTests.releaseprod.xcconfig"; sourceTree = ""; }; - BA30427D2B20B299009B64B7 /* SocialAuthError.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SocialAuthError.swift; sourceTree = ""; }; BA4AFB412B5A7A0900A21367 /* VideoDownloadQualityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoDownloadQualityView.swift; sourceTree = ""; }; 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 = ""; }; - BA8B3A2E2AD546A700D25EF5 /* DebugLog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebugLog.swift; sourceTree = ""; }; - BA8FA6602AD5974300EA029A /* AppleAuthProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleAuthProvider.swift; sourceTree = ""; }; BA8FA6672AD59A5700EA029A /* SocialAuthButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SocialAuthButton.swift; sourceTree = ""; }; - 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 = ""; }; - BA981BCD2B8F5C49005707C2 /* Sequence+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Sequence+Extensions.swift"; sourceTree = ""; }; BA981BCF2B91ED50005707C2 /* FullScreenProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FullScreenProgressView.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 = ""; }; 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 = ""; }; BAFB99832B0E282E007D09F9 /* MicrosoftConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MicrosoftConfig.swift; sourceTree = ""; }; BAFB998F2B14B377007D09F9 /* GoogleConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoogleConfig.swift; sourceTree = ""; }; @@ -419,7 +367,6 @@ E055A5382B18DC95008D9E5E /* Theme.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Theme.framework; sourceTree = BUILT_PRODUCTS_DIR; }; E09179FC2B0F204D002AB695 /* ConfigTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ConfigTests.swift; sourceTree = ""; }; E0D586192B2FF74C009B4BA7 /* DiscoveryConfig.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryConfig.swift; sourceTree = ""; }; - E0D5861B2B2FF85B009B4BA7 /* RawStringExtactable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RawStringExtactable.swift; sourceTree = ""; }; E0D586352B314CD3009B4BA7 /* LogistrationBottomView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogistrationBottomView.swift; sourceTree = ""; }; E8D9725130C85DA55AD474A4 /* Pods-CoreTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-CoreTests.debug.xcconfig"; path = "Target Support Files/Pods-CoreTests/Pods-CoreTests.debug.xcconfig"; sourceTree = ""; }; F4E50CE1DB6AA77E9B5D09EF /* Pods-App-Core-CoreTests.debugprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Core-CoreTests.debugprod.xcconfig"; path = "Target Support Files/Pods-App-Core-CoreTests/Pods-App-Core-CoreTests.debugprod.xcconfig"; sourceTree = ""; }; @@ -433,6 +380,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + CE7CAF392CC1561E00E0AC9D /* OEXFoundation in Frameworks */, 0716946D296D996900E3DED6 /* Core.framework in Frameworks */, 5E58740A2AA9DF20F4644191 /* Pods_App_Core_CoreTests.framework in Frameworks */, ); @@ -442,11 +390,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - BAF0D4CB2AD6AE14007AC334 /* FacebookLogin in Frameworks */, - 025EF2F62971740000B838AB /* YouTubePlayerKit in Frameworks */, + CE57127C2CD109DB00D4AB17 /* OEXFoundation in Frameworks */, C8C446EF233F81B9FABB77D2 /* Pods_App_Core.framework in Frameworks */, - 142EDD6C2B831D1400F9F320 /* BranchSDK in Frameworks */, - BA8FA66C2AD59BBC00EA029A /* GoogleSignIn in Frameworks */, 02AA27942C2C1B88006F5B6A /* ZipArchive in Frameworks */, E055A5392B18DC95008D9E5E /* Theme.framework in Frameworks */, ); @@ -527,30 +472,9 @@ 0283347E28D4DCC100C828FC /* Extensions */ = { isa = PBXGroup; children = ( - 06078B6E2BA49C3100576798 /* Dictionary+JSON.swift */, - 06078B6F2BA49C3100576798 /* String+JSON.swift */, - 02F164362902A9EB0090DDEF /* StringExtension.swift */, - 024BE3DE29B2615500BCDEE2 /* CGColorExtension.swift */, 0283347F28D4DCD200C828FC /* ViewExtension.swift */, 02F6EF4928D9F0A700835477 /* DateExtension.swift */, - 02F98A7E28F81EE900DE94C0 /* Container+App.swift */, - 02228B302C2232D2009A5F28 /* IntExtension.swift */, - 027BD3BB2909478B00392132 /* UIResponder+CurrentResponder.swift */, - 027BD3BA2909478B00392132 /* UIView+EnclosingScrollView.swift */, - 02E225AF291D29EB0067769A /* UrlExtension.swift */, - 02B3E3B22930198600A50475 /* AVPlayerViewControllerExtension.swift */, - 07460FE0294B706200F70538 /* CollectionExtension.swift */, 07460FE2294B72D700F70538 /* Notification.swift */, - 02B2B593295C5C7A00914876 /* Thread.swift */, - 0727878228D31287002E9142 /* DispatchQueue+App.swift */, - 07DDFCBC29A780BB00572595 /* UINavigationController+Animation.swift */, - 02284C172A3B1AE00007117F /* UIApplicationExtension.swift */, - BA8B3A2E2AD546A700D25EF5 /* DebugLog.swift */, - BADB3F5A2AD6EC56004D5CFA /* ResultExtension.swift */, - 02D400602B0678190029D168 /* SKStoreReviewControllerExtension.swift */, - E0D5861B2B2FF85B009B4BA7 /* RawStringExtactable.swift */, - BA981BCD2B8F5C49005707C2 /* Sequence+Extensions.swift */, - 029EE3EC2BF6650500F64F33 /* Bundle.swift */, ); path = Extensions; sourceTree = ""; @@ -741,16 +665,9 @@ 0770DE2828D0928B006D8A5D /* Network */ = { isa = PBXGroup; children = ( - 0770DE2928D0929E006D8A5D /* HTTPTask.swift */, - 0770DE2B28D092B3006D8A5D /* NetworkLogger.swift */, - 0770DE2D28D09743006D8A5D /* API.swift */, - 0770DE2F28D09793006D8A5D /* EndPointType.swift */, 0727877828D23BE0002E9142 /* RequestInterceptor.swift */, - 0727877A28D24A1D002E9142 /* HeadersRedirectHandler.swift */, - 0727877E28D25B24002E9142 /* Alamofire+Error.swift */, 0236961E28F9A2F600EEF206 /* AuthEndpoint.swift */, 029A132B2C2471F8005FB830 /* OfflineSyncEndpoint.swift */, - 0255D55729362839004DBC1A /* UploadBodyEncoding.swift */, 02A4833929B8A9AB00D33F33 /* DownloadManager.swift */, 029A13272C246AE6005FB830 /* OfflineSyncManager.swift */, ); @@ -838,27 +755,6 @@ path = FullScreenErrorView; sourceTree = ""; }; - BA30427C2B20B235009B64B7 /* SocialAuth */ = { - isa = PBXGroup; - children = ( - BA30427E2B20B299009B64B7 /* Error */, - BA8FA6602AD5974300EA029A /* AppleAuthProvider.swift */, - BA8FA6692AD59B5500EA029A /* GoogleAuthProvider.swift */, - BA8FA66D2AD59E7D00EA029A /* FacebookAuthProvider.swift */, - BA8FA66F2AD59EA300EA029A /* MicrosoftAuthProvider.swift */, - BA76135B2B21BC7300B599B7 /* SocialAuthResponse.swift */, - ); - path = SocialAuth; - sourceTree = ""; - }; - BA30427E2B20B299009B64B7 /* Error */ = { - isa = PBXGroup; - children = ( - BA30427D2B20B299009B64B7 /* SocialAuthError.swift */, - ); - path = Error; - sourceTree = ""; - }; BA593F1A2AF8E487009ADB51 /* ScrollSlidingTabBar */ = { isa = PBXGroup; children = ( @@ -871,7 +767,6 @@ BA8FA65F2AD5973500EA029A /* Providers */ = { isa = PBXGroup; children = ( - BA30427C2B20B235009B64B7 /* SocialAuth */, BAD9CA3D2B29BB1A00DE790A /* Ajax */, ); path = Providers; @@ -942,7 +837,6 @@ DBF6F2402B014ADA0098414B /* FirebaseConfig.swift */, A5F4E7B42B61544A00ACD166 /* BrazeConfig.swift */, A595689A2B6173DF00ED4F90 /* BranchConfig.swift */, - A51CDBE62B6D21F2009B6D4E /* SegmentConfig.swift */, DBF6F2492B0380E00098414B /* FeaturesConfig.swift */, DBF6F2452B01DAFE0098414B /* AgreementConfig.swift */, BAFB99812B0E2354007D09F9 /* FacebookConfig.swift */, @@ -952,7 +846,6 @@ 02CA59832BD7DDBE00D517AA /* DashboardConfig.swift */, A53A32342B233DEC005FE38A /* ThemeConfig.swift */, E0D586192B2FF74C009B4BA7 /* DiscoveryConfig.swift */, - 14D912D82C2553C70077CCCE /* FullStoryConfig.swift */, ); path = Config; sourceTree = ""; @@ -972,7 +865,6 @@ children = ( E09179FC2B0F204D002AB695 /* ConfigTests.swift */, BAD9CA412B2B140100DE790A /* AgreementConfigTests.swift */, - 14D912DA2C257E9E0077CCCE /* FullStoryConfigTests.swift */, ); path = Configuration; sourceTree = ""; @@ -1009,6 +901,7 @@ 07169466296D996800E3DED6 /* Frameworks */, 07169467296D996800E3DED6 /* Resources */, C9AA9371F83D4B112F310DB8 /* [CP] Copy Pods Resources */, + CE7CAF3B2CC1561E00E0AC9D /* Embed Frameworks */, ); buildRules = ( ); @@ -1031,6 +924,7 @@ 0770DE0528D07831006D8A5D /* Frameworks */, 0770DE0628D07831006D8A5D /* Resources */, 49BAD0663C27D73B9115401F /* [CP] Copy Pods Resources */, + CE57127E2CD109DB00D4AB17 /* Embed Frameworks */, ); buildRules = ( ); @@ -1038,11 +932,8 @@ ); name = Core; packageProductDependencies = ( - 025EF2F52971740000B838AB /* YouTubePlayerKit */, - BA8FA66B2AD59BBC00EA029A /* GoogleSignIn */, - BAF0D4CA2AD6AE14007AC334 /* FacebookLogin */, - 142EDD6B2B831D1400F9F320 /* BranchSDK */, 02AA27932C2C1B88006F5B6A /* ZipArchive */, + CE57127B2CD109DB00D4AB17 /* OEXFoundation */, ); productName = Core; productReference = 0770DE0828D07831006D8A5D /* Core.framework */; @@ -1079,11 +970,8 @@ ); mainGroup = 0770DDFE28D07831006D8A5D; packageReferences = ( - 025EF2F42971740000B838AB /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */, - BA8FA65E2AD574D700EA029A /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */, - BA8FA6712AD6ABA300EA029A /* XCRemoteSwiftPackageReference "facebook-ios-sdk" */, - 142EDD6A2B831D1400F9F320 /* XCRemoteSwiftPackageReference "ios-branch-sdk-spm" */, 02AA27922C2C1B61006F5B6A /* XCRemoteSwiftPackageReference "ZipArchive" */, + CE924BE42CD8F8E4000137CA /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */, ); productRefGroup = 0770DE0928D07831006D8A5D /* Products */; projectDirPath = ""; @@ -1223,7 +1111,6 @@ files = ( BAD9CA422B2B140100DE790A /* AgreementConfigTests.swift in Sources */, CE953A3B2CD0DA940023D667 /* CoreMock.generated.swift in Sources */, - 14D912DB2C257E9E0077CCCE /* FullStoryConfigTests.swift in Sources */, E09179FD2B0F204E002AB695 /* ConfigTests.swift in Sources */, CE54C2D22CC80D8500E529F9 /* DownloadManagerTests.swift in Sources */, ); @@ -1243,22 +1130,17 @@ 06619EAA2B8F2936001FAADE /* ReadabilityModifier.swift in Sources */, BAFB99902B14B377007D09F9 /* GoogleConfig.swift in Sources */, 029A132C2C2471F8005FB830 /* OfflineSyncEndpoint.swift in Sources */, - 02E225B0291D29EB0067769A /* UrlExtension.swift in Sources */, 02CF46C829546AA200A698EE /* NoCachedDataError.swift in Sources */, 0727877728D23847002E9142 /* DataLayer.swift in Sources */, 0241666B28F5A78B00082765 /* HTMLFormattedText.swift in Sources */, 02D800CC29348F460099CF16 /* ImagePicker.swift in Sources */, 027BD3B92909476200392132 /* KeyboardAvoidingModifier.swift in Sources */, BA4AFB422B5A7A0900A21367 /* VideoDownloadQualityView.swift in Sources */, - 0770DE2C28D092B3006D8A5D /* NetworkLogger.swift in Sources */, 02A8C5812C05DBB4004B91FF /* Data_EnrollmentsStatus.swift in Sources */, 06BEEA0E2B6A55C500D25A97 /* ColorInversionInjection.swift in Sources */, 064987972B4D69FF0071642A /* WebView.swift in Sources */, - 0770DE2A28D0929E006D8A5D /* HTTPTask.swift in Sources */, 06619EAD2B90918B001FAADE /* ReadabilityInjection.swift in Sources */, 029A13262C2457D9005FB830 /* OfflineProgress.swift in Sources */, - 02284C182A3B1AE00007117F /* UIApplicationExtension.swift in Sources */, - 0255D5582936283A004DBC1A /* UploadBodyEncoding.swift in Sources */, 06619EAF2B973B25001FAADE /* AccessibilityInjection.swift in Sources */, BAFB99822B0E2354007D09F9 /* FacebookConfig.swift in Sources */, 02935B732BCECAD000B22F66 /* Data_PrimaryEnrollment.swift in Sources */, @@ -1267,9 +1149,7 @@ 0727877D28D25212002E9142 /* ProgressBar.swift in Sources */, BA981BD02B91ED50005707C2 /* FullScreenProgressView.swift in Sources */, 0236961F28F9A2F600EEF206 /* AuthEndpoint.swift in Sources */, - 02B3E3B32930198600A50475 /* AVPlayerViewControllerExtension.swift in Sources */, BAD9CA332B28A8F300DE790A /* AjaxProvider.swift in Sources */, - A51CDBE72B6D21F2009B6D4E /* SegmentConfig.swift in Sources */, 0282DA7328F98CC9003C3F07 /* WebUnitView.swift in Sources */, E0D586362B314CD3009B4BA7 /* LogistrationBottomView.swift in Sources */, 0727878128D25EFD002E9142 /* SnackBarView.swift in Sources */, @@ -1280,32 +1160,25 @@ 0727877028D23411002E9142 /* Config.swift in Sources */, CFC84952299F8B890055E497 /* Debounce.swift in Sources */, 0236F3B728F4351E0050F09B /* CourseButton.swift in Sources */, - 0727878328D31287002E9142 /* DispatchQueue+App.swift in Sources */, 028F9F37293A44C700DE65D0 /* Data_ResetPassword.swift in Sources */, 022C64E429AE0191000F532B /* TextWithUrls.swift in Sources */, 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 */, 9784D47E2BF7762800AFEFFF /* FullScreenErrorView.swift in Sources */, 027BD3AE2909475000392132 /* KeyboardScrollerOptions.swift in Sources */, - 14D912D92C2553C70077CCCE /* FullStoryConfig.swift in Sources */, BAFB99922B14E23D007D09F9 /* AppleSignInConfig.swift in Sources */, 141F1D302B7328D4009E81EB /* WebviewCookiesUpdateProtocol.swift in Sources */, 064987992B4D69FF0071642A /* WebViewScriptInjectionProtocol.swift in Sources */, - 027BD3BE2909478B00392132 /* UIResponder+CurrentResponder.swift in Sources */, 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 */, 02C917F029CDA99E00DBB8BD /* Data_Enrollments.swift in Sources */, 024FCD0028EF1CD300232339 /* WebBrowser.swift in Sources */, 027BD3B52909475900392132 /* KeyboardStateObserver.swift in Sources */, @@ -1313,7 +1186,6 @@ 0283347D28D4D3DE00C828FC /* Data_Discovery.swift in Sources */, 064987962B4D69FF0071642A /* WebviewMessage.swift in Sources */, 02EBC75B2C19DE3D00BE182C /* CourseForSync.swift in Sources */, - 07DDFCBD29A780BB00572595 /* UINavigationController+Animation.swift in Sources */, 023A4DD4299E66BD006C0E48 /* OfflineSnackBarView.swift in Sources */, 021D925728DCF12900ACC565 /* AlertView.swift in Sources */, 027BD3A82909474200392132 /* KeyboardAvoidingViewController.swift in Sources */, @@ -1322,28 +1194,21 @@ 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 */, - BA76135C2B21BC7300B599B7 /* SocialAuthResponse.swift in Sources */, 020306CC2932C0C4000949EA /* PickerView.swift in Sources */, 027BD3C52909707700392132 /* Shake.swift in Sources */, 027BD39C2908810C00392132 /* RegisterUser.swift in Sources */, 071009C428D1C9D000344290 /* StyledButton.swift in Sources */, - 07460FE1294B706200F70538 /* CollectionExtension.swift in Sources */, 064987932B4D69FF0071642A /* DragAndDropCssInjection.swift in Sources */, 027BD3B42909475900392132 /* KeyboardState.swift in Sources */, 027BD3922907D88F00392132 /* Data_RegistrationFields.swift in Sources */, 07460FE3294B72D700F70538 /* Notification.swift in Sources */, - 0727877F28D25B24002E9142 /* Alamofire+Error.swift in Sources */, 02A4833829B8A8F900D33F33 /* CoreDataModel.xcdatamodeld in Sources */, 064987952B4D69FF0071642A /* SurveyCssInjection.swift in Sources */, 02E224DB2BB76B3E00EF1ADB /* DynamicOffsetView.swift in Sources */, 0259104A29C4A5B6004B5A55 /* UserSettings.swift in Sources */, 021D925028DC89D100ACC565 /* UserProfile.swift in Sources */, 071009D028D1E3A600344290 /* Constants.swift in Sources */, - 02228B312C2232D2009A5F28 /* IntExtension.swift in Sources */, 0770DE1928D0847D006D8A5D /* BaseRouter.swift in Sources */, - BA30427F2B20B320009B64B7 /* SocialAuthError.swift in Sources */, 02286D162C106393005EEC8D /* CourseDates.swift in Sources */, 0284DBFE28D48C5300830893 /* CourseItem.swift in Sources */, 0248C92329C075EF00DC8402 /* CourseBlockModel.swift in Sources */, @@ -1357,38 +1222,25 @@ 02AFCC182AEFDB24000360F0 /* ThirdPartyMailClient.swift in Sources */, 0233D5712AF13EC800BAC8BD /* SelectMailClientView.swift in Sources */, BAFB99842B0E282E007D09F9 /* MicrosoftConfig.swift in Sources */, - 02B2B594295C5C7A00914876 /* Thread.swift in Sources */, - E0D5861C2B2FF85B009B4BA7 /* RawStringExtactable.swift in Sources */, 02EBC7592C19DE1100BE182C /* CalendarManagerProtocol.swift in Sources */, - 027BD3BD2909478B00392132 /* UIView+EnclosingScrollView.swift in Sources */, BA8FA6682AD59A5700EA029A /* SocialAuthButton.swift in Sources */, - 02D400612B0678190029D168 /* SKStoreReviewControllerExtension.swift in Sources */, 02A4833C29B8C57800D33F33 /* DownloadView.swift in Sources */, 027BD3AD2909475000392132 /* KeyboardScroller.swift in Sources */, 070019A528F6F17900D5FC78 /* Data_Media.swift in Sources */, 024D723529C8BB1A006D36ED /* NavigationBar.swift in Sources */, - BA8B3A2F2AD546A700D25EF5 /* DebugLog.swift in Sources */, 0727877928D23BE0002E9142 /* RequestInterceptor.swift in Sources */, - BA8FA66E2AD59E7D00EA029A /* FacebookAuthProvider.swift in Sources */, - 0770DE2E28D09743006D8A5D /* API.swift in Sources */, 028CE96929858ECC00B6B1C3 /* FlexibleKeyboardInputView.swift in Sources */, 027BD3A92909474200392132 /* KeyboardAvoidingViewControllerRepr.swift in Sources */, - 02F98A7F28F81EE900DE94C0 /* Container+App.swift in Sources */, 0649879A2B4D69FF0071642A /* WebViewHTML.swift in Sources */, 02CA59842BD7DDBE00D517AA /* DashboardConfig.swift in Sources */, - 0727877B28D24A1D002E9142 /* HeadersRedirectHandler.swift in Sources */, 0236961B28F9A28B00EEF206 /* AuthInteractor.swift in Sources */, - 0770DE3028D09793006D8A5D /* EndPointType.swift in Sources */, 02935B752BCEE6D600B22F66 /* PrimaryEnrollment.swift in Sources */, 020C31C9290AC3F700D6DEA2 /* PickerFields.swift in Sources */, 02F6EF3B28D9B8EC00835477 /* CourseCellView.swift in Sources */, 023A1138291432FD00D0D354 /* FieldConfiguration.swift in Sources */, DBF6F24A2B0380E00098414B /* FeaturesConfig.swift in Sources */, - 02F164372902A9EB0090DDEF /* StringExtension.swift in Sources */, 0231CDBE2922422D00032416 /* CSSInjector.swift in Sources */, 064987982B4D69FF0071642A /* CSSInjectionProtocol.swift in Sources */, - 029EE3ED2BF6650500F64F33 /* Bundle.swift in Sources */, - BADB3F5B2AD6EC56004D5CFA /* ResultExtension.swift in Sources */, 029A132A2C2471DF005FB830 /* OfflineSyncRepository.swift in Sources */, 0236961928F9A26900EEF206 /* AuthRepository.swift in Sources */, 023A1136291432B200D0D354 /* RegistrationTextField.swift in Sources */, @@ -1399,7 +1251,6 @@ 029A13302C2479E7005FB830 /* OfflineSyncInteractor.swift in Sources */, 0604C9AA2B22FACF00AD5DBF /* UIComponentsConfig.swift in Sources */, 027BD3B82909476200392132 /* DismissKeyboardTapViewModifier.swift in Sources */, - 024BE3DF29B2615500BCDEE2 /* CGColorExtension.swift in Sources */, 0770DE6128D0B2CB006D8A5D /* Assets.swift in Sources */, 07E0939F2B308D2800F1E4B2 /* Data_Certificate.swift in Sources */, 020D72F42BB76DFE00773319 /* VisualEffectView.swift in Sources */, @@ -2429,14 +2280,6 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 025EF2F42971740000B838AB /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/SvenTiigi/YouTubePlayerKit"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.9.0; - }; - }; 02AA27922C2C1B61006F5B6A /* XCRemoteSwiftPackageReference "ZipArchive" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/ZipArchive/ZipArchive.git"; @@ -2445,20 +2288,12 @@ minimumVersion = 2.5.5; }; }; - 142EDD6A2B831D1400F9F320 /* XCRemoteSwiftPackageReference "ios-branch-sdk-spm" */ = { + CE924BE42CD8F8E4000137CA /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/BranchMetrics/ios-branch-sdk-spm"; + repositoryURL = "https://github.com/openedx/openedx-app-foundation-ios/"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 3.6.3; - }; - }; - BA8FA65E2AD574D700EA029A /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/google/GoogleSignIn-iOS"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 8.0.0; + kind = exactVersion; + version = 1.0.0; }; }; BA8FA6712AD6ABA300EA029A /* XCRemoteSwiftPackageReference "facebook-ios-sdk" */ = { @@ -2472,30 +2307,18 @@ /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 025EF2F52971740000B838AB /* YouTubePlayerKit */ = { - isa = XCSwiftPackageProductDependency; - package = 025EF2F42971740000B838AB /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */; - productName = YouTubePlayerKit; - }; 02AA27932C2C1B88006F5B6A /* ZipArchive */ = { isa = XCSwiftPackageProductDependency; package = 02AA27922C2C1B61006F5B6A /* XCRemoteSwiftPackageReference "ZipArchive" */; productName = ZipArchive; }; - 142EDD6B2B831D1400F9F320 /* BranchSDK */ = { - isa = XCSwiftPackageProductDependency; - package = 142EDD6A2B831D1400F9F320 /* XCRemoteSwiftPackageReference "ios-branch-sdk-spm" */; - productName = BranchSDK; - }; - BA8FA66B2AD59BBC00EA029A /* GoogleSignIn */ = { + CE57127B2CD109DB00D4AB17 /* OEXFoundation */ = { isa = XCSwiftPackageProductDependency; - package = BA8FA65E2AD574D700EA029A /* XCRemoteSwiftPackageReference "GoogleSignIn-iOS" */; - productName = GoogleSignIn; + productName = OEXFoundation; }; - BAF0D4CA2AD6AE14007AC334 /* FacebookLogin */ = { + CE7CAF382CC1561E00E0AC9D /* OEXFoundation */ = { isa = XCSwiftPackageProductDependency; - package = BA8FA6712AD6ABA300EA029A /* XCRemoteSwiftPackageReference "facebook-ios-sdk" */; - productName = FacebookLogin; + productName = OEXFoundation; }; /* End XCSwiftPackageProductDependency section */ diff --git a/Core/Core/AvoidingHelpers/Scroller/KeyboardScroller.swift b/Core/Core/AvoidingHelpers/Scroller/KeyboardScroller.swift index ff5c17c11..2d5fe602e 100644 --- a/Core/Core/AvoidingHelpers/Scroller/KeyboardScroller.swift +++ b/Core/Core/AvoidingHelpers/Scroller/KeyboardScroller.swift @@ -1,6 +1,7 @@ // import UIKit +import OEXFoundation final class KeyboardScroller { static func scroll( @@ -57,7 +58,7 @@ final class KeyboardScroller { self.options = options self.partialAvoidingPadding = partialAvoidingPadding - globalWindow = UIApplication.shared.keyWindow ?? UIWindow() + globalWindow = UIApplication.shared.oexKeyWindow ?? UIWindow() calculateGlobalFrames() } diff --git a/Core/Core/Configuration/Config/AgreementConfig.swift b/Core/Core/Configuration/Config/AgreementConfig.swift index d6cfae197..df29f8d3f 100644 --- a/Core/Core/Configuration/Config/AgreementConfig.swift +++ b/Core/Core/Configuration/Config/AgreementConfig.swift @@ -6,6 +6,7 @@ // import Foundation +import OEXFoundation private enum AgreementKeys: String, RawStringExtractable { case privacyPolicyURL = "PRIVACY_POLICY_URL" diff --git a/Core/Core/Configuration/Config/BranchConfig.swift b/Core/Core/Configuration/Config/BranchConfig.swift index 6f2a785b6..ffc44793a 100644 --- a/Core/Core/Configuration/Config/BranchConfig.swift +++ b/Core/Core/Configuration/Config/BranchConfig.swift @@ -6,6 +6,7 @@ // import Foundation +import OEXFoundation private enum BranchKeys: String, RawStringExtractable { case enabled = "ENABLED" diff --git a/Core/Core/Configuration/Config/BrazeConfig.swift b/Core/Core/Configuration/Config/BrazeConfig.swift index 0cbc10db8..9a882a1c5 100644 --- a/Core/Core/Configuration/Config/BrazeConfig.swift +++ b/Core/Core/Configuration/Config/BrazeConfig.swift @@ -6,6 +6,7 @@ // import Foundation +import OEXFoundation private enum BrazeKeys: String, RawStringExtractable { case enabled = "ENABLED" diff --git a/Core/Core/Configuration/Config/Config.swift b/Core/Core/Configuration/Config/Config.swift index adad33a0d..dbd29f0de 100644 --- a/Core/Core/Configuration/Config/Config.swift +++ b/Core/Core/Configuration/Config/Config.swift @@ -32,10 +32,8 @@ public protocol ConfigProtocol { var dashboard: DashboardConfig { get } var braze: BrazeConfig { get } var branch: BranchConfig { get } - var segment: SegmentConfig { get } var program: DiscoveryConfig { get } var URIScheme: String { get } - var fullStory: FullStoryConfig { get } } public enum TokenType: String { diff --git a/Core/Core/Configuration/Config/DashboardConfig.swift b/Core/Core/Configuration/Config/DashboardConfig.swift index cd2b335b1..c87045ced 100644 --- a/Core/Core/Configuration/Config/DashboardConfig.swift +++ b/Core/Core/Configuration/Config/DashboardConfig.swift @@ -6,6 +6,7 @@ // import Foundation +import OEXFoundation public enum DashboardConfigType: String { case gallery diff --git a/Core/Core/Configuration/Config/DiscoveryConfig.swift b/Core/Core/Configuration/Config/DiscoveryConfig.swift index d6d29de2b..a646658bb 100644 --- a/Core/Core/Configuration/Config/DiscoveryConfig.swift +++ b/Core/Core/Configuration/Config/DiscoveryConfig.swift @@ -6,6 +6,7 @@ // import Foundation +import OEXFoundation public enum DiscoveryConfigType: String { case native diff --git a/Core/Core/Configuration/Config/FullStoryConfig.swift b/Core/Core/Configuration/Config/FullStoryConfig.swift deleted file mode 100644 index 4e1181dc3..000000000 --- a/Core/Core/Configuration/Config/FullStoryConfig.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// FullStoryConfig.swift -// Core -// -// Created by Saeed Bashir on 6/21/24. -// - -import Foundation - -private enum Keys: String, RawStringExtractable { - case enabled = "ENABLED" - case orgID = "ORG_ID" -} - -public final class FullStoryConfig { - public var enabled: Bool = false - public var orgID: String = "" - - init(dictionary: [String: AnyObject]) { - orgID = dictionary[Keys.orgID] as? String ?? "" - enabled = !orgID.isEmpty && dictionary[Keys.enabled] as? Bool ?? false - } -} - -private let configKey = "FULLSTORY" -extension Config { - public var fullStory: FullStoryConfig { - FullStoryConfig(dictionary: self[configKey] as? [String: AnyObject] ?? [:]) - } -} diff --git a/Core/Core/Configuration/Config/MicrosoftConfig.swift b/Core/Core/Configuration/Config/MicrosoftConfig.swift index 4175a53e2..eb3d4d825 100644 --- a/Core/Core/Configuration/Config/MicrosoftConfig.swift +++ b/Core/Core/Configuration/Config/MicrosoftConfig.swift @@ -14,7 +14,7 @@ private enum MicrosoftKeys: String { public final class MicrosoftConfig: NSObject { public var enabled: Bool = false - private(set) var appID: String? + public private(set) var appID: String? private var requiredKeysAvailable: Bool { return appID != nil diff --git a/Core/Core/Configuration/Config/SegmentConfig.swift b/Core/Core/Configuration/Config/SegmentConfig.swift deleted file mode 100644 index 937a78015..000000000 --- a/Core/Core/Configuration/Config/SegmentConfig.swift +++ /dev/null @@ -1,31 +0,0 @@ -// -// 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() - writeKey = dictionary[SegmentKeys.writeKey] as? String ?? "" - enabled = dictionary[SegmentKeys.enabled] as? Bool == true && !writeKey.isEmpty - } -} - -private let segmentKey = "SEGMENT_IO" -extension Config { - public var segment: SegmentConfig { - SegmentConfig(dictionary: self[segmentKey] as? [String: AnyObject] ?? [:]) - } -} diff --git a/Core/Core/Configuration/Config/UIComponentsConfig.swift b/Core/Core/Configuration/Config/UIComponentsConfig.swift index 16c42ad4c..c62b20774 100644 --- a/Core/Core/Configuration/Config/UIComponentsConfig.swift +++ b/Core/Core/Configuration/Config/UIComponentsConfig.swift @@ -6,6 +6,7 @@ // import Foundation +import OEXFoundation private enum Keys: String, RawStringExtractable { case courseDropDownNavigationEnabled = "COURSE_DROPDOWN_NAVIGATION_ENABLED" diff --git a/Core/Core/Data/Repository/AuthRepository.swift b/Core/Core/Data/Repository/AuthRepository.swift index 2c838e679..20e89e603 100644 --- a/Core/Core/Data/Repository/AuthRepository.swift +++ b/Core/Core/Data/Repository/AuthRepository.swift @@ -6,6 +6,7 @@ // import Foundation +import OEXFoundation public protocol AuthRepositoryProtocol { func login(username: String, password: String) async throws -> User diff --git a/Core/Core/Data/Repository/OfflineSyncRepository.swift b/Core/Core/Data/Repository/OfflineSyncRepository.swift index 386daf7ab..67a535719 100644 --- a/Core/Core/Data/Repository/OfflineSyncRepository.swift +++ b/Core/Core/Data/Repository/OfflineSyncRepository.swift @@ -6,6 +6,7 @@ // import Foundation +import OEXFoundation public protocol OfflineSyncRepositoryProtocol { func submitOfflineProgress(courseID: String, blockID: String, data: String) async throws -> Bool diff --git a/Core/Core/Extensions/AVPlayerViewControllerExtension.swift b/Core/Core/Extensions/AVPlayerViewControllerExtension.swift deleted file mode 100644 index 1f9b3e27e..000000000 --- a/Core/Core/Extensions/AVPlayerViewControllerExtension.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// AVPlayerViewControllerExtension.swift -// Core -// -// Created by  Stepanok Ivan on 24.11.2022. -// - -import AVKit - -public extension AVPlayerViewController { - func enterFullScreen(animated: Bool) { - perform(NSSelectorFromString("enterFullScreenAnimated:completionHandler:"), with: animated, with: nil) - } - func exitFullScreen(animated: Bool) { - perform(NSSelectorFromString("exitFullScreenAnimated:completionHandler:"), with: animated, with: nil) - } -} diff --git a/Core/Core/Extensions/Bundle.swift b/Core/Core/Extensions/Bundle.swift deleted file mode 100644 index b46037f19..000000000 --- a/Core/Core/Extensions/Bundle.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// Bundle.swift -// Core -// -// Created by  Stepanok Ivan on 16.05.2024. -// - -import Foundation - -public extension Bundle { - var applicationName: String? { - object(forInfoDictionaryKey: "CFBundleDisplayName") as? String ?? - object(forInfoDictionaryKey: "CFBundleName") as? String - } -} diff --git a/Core/Core/Extensions/CGColorExtension.swift b/Core/Core/Extensions/CGColorExtension.swift deleted file mode 100644 index f1e871974..000000000 --- a/Core/Core/Extensions/CGColorExtension.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// CGColorExtension.swift -// Core -// -// Created by  Stepanok Ivan on 03.03.2023. -// - -import Foundation -import SwiftUI - -public extension CGColor { - var hexString: String? { - guard let components = self.components, components.count >= 3 else { - return nil - } - let red = components[0] - let green = components[1] - let blue = components[2] - let hexString = String( - format: "#%02lX%02lX%02lX", - lroundf(Float(red * 255)), - lroundf(Float(green * 255)), - lroundf(Float(blue * 255)) - ) - return hexString - } -} - -public extension Color { - func uiColor() -> UIColor { - return UIColor(self) - } -} diff --git a/Core/Core/Extensions/CollectionExtension.swift b/Core/Core/Extensions/CollectionExtension.swift deleted file mode 100644 index ba2ff088a..000000000 --- a/Core/Core/Extensions/CollectionExtension.swift +++ /dev/null @@ -1,15 +0,0 @@ -// -// CollectionExtension.swift -// Core -// -// Created by Vladimir Chekyrta on 15.12.2022. -// - -import Foundation - -public extension Collection { - /// Returns the element at the specified index if it is within bounds, otherwise nil. - subscript (safe index: Index) -> Element? { - return indices.contains(index) ? self[index] : nil - } -} diff --git a/Core/Core/Extensions/Container+App.swift b/Core/Core/Extensions/Container+App.swift deleted file mode 100644 index 0362d708c..000000000 --- a/Core/Core/Extensions/Container+App.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// Container+App.swift -// Core -// -// Created by  Stepanok Ivan on 13.10.2022. -// - -import Foundation -import Swinject -import UIKit - -public extension Container { - static var shared: Container = { - let container = Container() - return container - }() -} - -public extension UIViewController { - var diContainer: Container { - return Container.shared - } -} diff --git a/Core/Core/Extensions/DebugLog.swift b/Core/Core/Extensions/DebugLog.swift deleted file mode 100644 index 1ceb35482..000000000 --- a/Core/Core/Extensions/DebugLog.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// DebugLog.swift -// Core -// -// Created by Eugene Yatsenko on 10.10.2023. -// - -import Foundation - -public func debugLog( - _ item: Any..., - filename: String = #file, - line: Int = #line, - funcname: String = #function -) { -#if DEBUG - print( - """ - 🕗 \(Date()) - 📄 \(filename.components(separatedBy: "/").last ?? "") \(line) \(funcname) - ℹ️ \(item) - """ - ) -#endif -} diff --git a/Core/Core/Extensions/Dictionary+JSON.swift b/Core/Core/Extensions/Dictionary+JSON.swift deleted file mode 100644 index 938cef881..000000000 --- a/Core/Core/Extensions/Dictionary+JSON.swift +++ /dev/null @@ -1,18 +0,0 @@ -// -// Dictionary+JSON.swift -// Core -// -// Created by Vadim Kuznetsov on 13.03.24. -// - -import Foundation - -public extension Dictionary where Key == String, Value == String { - 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/DispatchQueue+App.swift b/Core/Core/Extensions/DispatchQueue+App.swift deleted file mode 100644 index cd9423cfc..000000000 --- a/Core/Core/Extensions/DispatchQueue+App.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// DispatchQueue+App.swift -// Core -// -// Created by Vladimir Chekyrta on 15.09.2022. -// - -import Foundation - -public func doAfter(_ delay: TimeInterval? = nil, _ closure: @escaping () -> Void) { - DispatchQueue.main.asyncAfter(deadline: .now() + (delay ?? 0), execute: closure) -} - -public func dispatchQueueMain(_ closure: @escaping () -> Void) { - DispatchQueue.main.async(execute: closure) -} diff --git a/Core/Core/Extensions/IntExtension.swift b/Core/Core/Extensions/IntExtension.swift deleted file mode 100644 index 46fe6ea0e..000000000 --- a/Core/Core/Extensions/IntExtension.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// IntExtension.swift -// Core -// -// Created by  Stepanok Ivan on 19.06.2024. -// - -import Foundation - -public extension Int { - func formattedFileSize() -> String { - if self == 0 { - return "0MB" - } - let sizeInMB = Double(self) / 1_048_576 - let sizeInGB = Double(self) / 1_073_741_824 - let formattedString: String - if sizeInGB >= 1 { - formattedString = String(format: "%.1fGB", sizeInGB).replacingOccurrences(of: ".0", with: "") - } else { - formattedString = String(format: "%.1fMB", sizeInMB).replacingOccurrences(of: ".0", with: "") - } - return formattedString - } -} diff --git a/Core/Core/Extensions/Notification.swift b/Core/Core/Extensions/Notification.swift index f70c71e3d..55ce77cc4 100644 --- a/Core/Core/Extensions/Notification.swift +++ b/Core/Core/Extensions/Notification.swift @@ -13,7 +13,6 @@ public extension Notification.Name { static let onCourseEnrolled = Notification.Name("onCourseEnrolled") static let onblockCompletionRequested = Notification.Name("onblockCompletionRequested") static let onTokenRefreshFailed = Notification.Name("onTokenRefreshFailed") - static let onActualVersionReceived = Notification.Name("onActualVersionReceived") static let onAppUpgradeAccountSettingsTapped = Notification.Name("onAppUpgradeAccountSettingsTapped") static let onNewVersionAvaliable = Notification.Name("onNewVersionAvaliable") static let webviewReloadNotification = Notification.Name("webviewReloadNotification") @@ -25,10 +24,3 @@ public extension Notification.Name { static let tryDownloadAgain = Notification.Name("tryDownloadAgain") static let refreshEnrollments = Notification.Name("refreshEnrollments") } - -public extension Notification { - enum UserInfoKey: String { - case isForced - } -} - diff --git a/Core/Core/Extensions/RawStringExtactable.swift b/Core/Core/Extensions/RawStringExtactable.swift deleted file mode 100644 index 1dcedb86c..000000000 --- a/Core/Core/Extensions/RawStringExtactable.swift +++ /dev/null @@ -1,27 +0,0 @@ -// -// RawStringExtactable.swift -// Core -// -// Created by SaeedBashir on 12/18/23. -// - -import Foundation - -public protocol RawStringExtractable { - var rawValue: String { get } -} - -public protocol DictionaryExtractionExtension { - associatedtype Key - associatedtype Value - subscript(key: Key) -> Value? { get } -} - -extension Dictionary: DictionaryExtractionExtension {} - -public extension DictionaryExtractionExtension where Self.Key == String { - - subscript(key: RawStringExtractable) -> Value? { - return self[key.rawValue] - } -} diff --git a/Core/Core/Extensions/ResultExtension.swift b/Core/Core/Extensions/ResultExtension.swift deleted file mode 100644 index d9a327768..000000000 --- a/Core/Core/Extensions/ResultExtension.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// ResultExtension.swift -// Core -// -// Created by Eugene Yatsenko on 11.10.2023. -// - -import Foundation - -extension Result { - @discardableResult - public func success(_ handler: (Success) -> Void) -> Self { - guard case let .success(value) = self else { return self } - handler(value) - return self - } - @discardableResult - public func failure(_ handler: (Failure) -> Void) -> Self { - guard case let .failure(error) = self else { return self } - handler(error) - return self - } -} diff --git a/Core/Core/Extensions/SKStoreReviewControllerExtension.swift b/Core/Core/Extensions/SKStoreReviewControllerExtension.swift deleted file mode 100644 index be214f661..000000000 --- a/Core/Core/Extensions/SKStoreReviewControllerExtension.swift +++ /dev/null @@ -1,20 +0,0 @@ -// -// SKStoreReviewControllerExtension.swift -// Core -// -// Created by  Stepanok Ivan on 16.11.2023. -// - -import Foundation -import StoreKit - -extension SKStoreReviewController { - public static func requestReviewInCurrentScene() { - if let scene = UIApplication.shared.connectedScenes - .first(where: { $0.activationState == .foregroundActive }) as? UIWindowScene { - DispatchQueue.main.async { - requestReview(in: scene) - } - } - } -} diff --git a/Core/Core/Extensions/Sequence+Extensions.swift b/Core/Core/Extensions/Sequence+Extensions.swift deleted file mode 100644 index 6feea9dd7..000000000 --- a/Core/Core/Extensions/Sequence+Extensions.swift +++ /dev/null @@ -1,14 +0,0 @@ -// -// Sequence+Extensions.swift -// Core -// -// Created by Eugene Yatsenko on 28.02.2024. -// - -import Foundation - -public extension Sequence { - func firstAs(_ type: T.Type = T.self) -> T? { - first { $0 is T } as? T - } -} diff --git a/Core/Core/Extensions/String+JSON.swift b/Core/Core/Extensions/String+JSON.swift deleted file mode 100644 index 6d801a886..000000000 --- a/Core/Core/Extensions/String+JSON.swift +++ /dev/null @@ -1,23 +0,0 @@ -// -// String+JSON.swift -// Core -// -// Created by Vadim Kuznetsov on 13.03.24. -// - -import Foundation - -public extension String { - 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/Core/Core/Extensions/StringExtension.swift b/Core/Core/Extensions/StringExtension.swift deleted file mode 100644 index e7e6805fb..000000000 --- a/Core/Core/Extensions/StringExtension.swift +++ /dev/null @@ -1,85 +0,0 @@ -// -// StringExtension.swift -// Core -// -// Created by  Stepanok Ivan on 29.09.2022. -// - -import Foundation - -public extension String { - - func find(from: String, to: String) -> [String] { - components(separatedBy: from).dropFirst().compactMap { sub in - (sub.range(of: to)?.lowerBound).flatMap { endRange in - String(sub[sub.startIndex ..< endRange]) - } - } - } - - func hideHtmlTagsAndUrls() -> String { - guard let detector = try? NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) else { - return self - } - return detector.stringByReplacingMatches( - in: self, - options: [], - range: NSRange(location: 0, length: self.utf16.count), - withTemplate: "" - ) - .replacingOccurrences(of: "<[^>]+>", with: "", options: String.CompareOptions.regularExpression, range: nil) - .replacingOccurrences(of: "

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

", with: "") - } - - func hideHtmlTags() -> String { - return self - .replacingOccurrences(of: "<[^>]+>", with: "", options: String.CompareOptions.regularExpression, range: nil) - } - - func extractURLs() -> [URL] { - var urls: [URL] = [] - do { - let detector = try NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue) - detector.enumerateMatches( - in: self, options: [], - range: NSRange(location: 0, length: self.count), - using: { (result, _, _) in - if let match = result, let url = match.url { - urls.append(url) - } - } - ) - } catch let error as NSError { - print(error.localizedDescription) - } - return urls - } - - func decodedHTMLEntities() -> String { - guard let regex = try? NSRegularExpression(pattern: "&#([0-9]+);", options: []) else { - return self - } - - let range = NSRange(location: 0, length: count) - let matches = regex.matches(in: self, options: [], range: range) - - var decodedString = self - for match in matches { - guard match.numberOfRanges > 1, - let range = Range(match.range(at: 1), in: self), - let codePoint = Int(self[range]), - let unicodeScalar = UnicodeScalar(codePoint) else { - continue - } - - let replacement = String(unicodeScalar) - guard let totalRange = Range(match.range, in: self) else { - continue - } - decodedString = decodedString.replacingOccurrences(of: self[totalRange], with: replacement) - } - - return decodedString - } -} diff --git a/Core/Core/Extensions/Thread.swift b/Core/Core/Extensions/Thread.swift deleted file mode 100644 index a78be4eac..000000000 --- a/Core/Core/Extensions/Thread.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// Thread.swift -// Core -// -// Created by  Stepanok Ivan on 28.12.2022. -// - -import Foundation - -extension Thread { - - var threadName: String { - if let currentOperationQueue = OperationQueue.current?.name { - return "OperationQueue: \(currentOperationQueue)" - } else if let underlyingDispatchQueue = OperationQueue.current?.underlyingQueue?.label { - return "DispatchQueue: \(underlyingDispatchQueue)" - } else { - let name = __dispatch_queue_get_label(nil) - return String(cString: name, encoding: .utf8) ?? Thread.current.description - } - } -} diff --git a/Core/Core/Extensions/UIApplicationExtension.swift b/Core/Core/Extensions/UIApplicationExtension.swift deleted file mode 100644 index 090eae3e3..000000000 --- a/Core/Core/Extensions/UIApplicationExtension.swift +++ /dev/null @@ -1,106 +0,0 @@ -// -// UIApplicationExtension.swift -// Core -// -// Created by  Stepanok Ivan on 15.06.2023. -// - -import UIKit -import Theme - -public extension UIApplication { - - var windows: [UIWindow]? { - let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene - return scene?.windows - } - - var window: UIWindow? { - windows?.first - } - - var keyWindow: UIWindow? { - windows?.first { $0.isKeyWindow } - } - - func endEditing(force: Bool = true) { - windows?.forEach { $0.endEditing(force) } - } - - class func topViewController( - controller: UIViewController? = UIApplication.shared.keyWindow?.rootViewController - ) -> UIViewController? { - if let navigationController = controller as? UINavigationController { - return topViewController(controller: navigationController.visibleViewController) - } - if let tabController = controller as? UITabBarController { - if let selected = tabController.selectedViewController { - return topViewController(controller: selected) - } - } - if let presented = controller?.presentedViewController { - return topViewController(controller: presented) - } - return controller - } - - var windowInsets: UIEdgeInsets { - guard let window = window else { - return .zero - } - - return window.safeAreaInsets - } -} - -extension UINavigationController { - open override func viewWillLayoutSubviews() { - super.viewWillLayoutSubviews() - navigationBar.topItem?.backButtonDisplayMode = .minimal - navigationBar.barTintColor = .clear - navigationBar.setBackgroundImage(UIImage(), for: .default) - navigationBar.shadowImage = UIImage() - - let image = CoreAssets.arrowLeft.image - navigationBar.backIndicatorImage = image.withTintColor(Theme.UIColors.accentXColor) - navigationBar.backIndicatorTransitionMaskImage = image.withTintColor(Theme.UIColors.accentXColor) - navigationBar.titleTextAttributes = [ - .foregroundColor: Theme.UIColors.navigationBarTintColor, - .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: .selected - ) - UISegmentedControl.appearance().selectedSegmentTintColor = UIColor(Theme.Colors.accentXColor) - - UIView.appearance(whenContainedInInstancesOf: [UIAlertController.self]).tintColor = Theme.UIColors.accentXColor - } -} - -extension UINavigationController: UIGestureRecognizerDelegate { - override open func viewDidLoad() { - super.viewDidLoad() - interactivePopGestureRecognizer?.delegate = self - navigationItem.backButtonDisplayMode = .minimal - } - - public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - if #available(iOS 17, *) { - return false - } else { - return viewControllers.count > 1 - } - } -} diff --git a/Core/Core/Extensions/UINavigationController+Animation.swift b/Core/Core/Extensions/UINavigationController+Animation.swift deleted file mode 100644 index 4f720f78c..000000000 --- a/Core/Core/Extensions/UINavigationController+Animation.swift +++ /dev/null @@ -1,41 +0,0 @@ -// -// UINavigationController+Animation.swift -// Core -// -// Created by Vladimir Chekyrta on 23.02.2023. -// - -import Foundation -import UIKit - -public extension UINavigationController { - - func popFade( - transitionType type: CATransitionType = .fade, - duration: CFTimeInterval = 0.3 - ) { - addTransition(transitionType: type, duration: duration) - popViewController(animated: false) - } - - func pushFade( - viewController vc: UIViewController, - transitionType type: CATransitionType = .fade, - duration: CFTimeInterval = 0.3 - ) { - addTransition(transitionType: type, duration: duration) - pushViewController(vc, animated: UIAccessibility.isVoiceOverRunning) - } - - private func addTransition( - transitionType type: CATransitionType = .fade, - duration: CFTimeInterval = 0.3 - ) { - let transition = CATransition() - transition.duration = duration - transition.timingFunction = CAMediaTimingFunction(name: CAMediaTimingFunctionName.easeInEaseOut) - transition.type = type - view.layer.add(transition, forKey: nil) - } - -} diff --git a/Core/Core/Extensions/UIResponder+CurrentResponder.swift b/Core/Core/Extensions/UIResponder+CurrentResponder.swift deleted file mode 100644 index f324d0596..000000000 --- a/Core/Core/Extensions/UIResponder+CurrentResponder.swift +++ /dev/null @@ -1,26 +0,0 @@ -// - -import Foundation -import UIKit - -extension UIResponder { - static var currentFirstResponder: UIResponder? { - _currentFirstResponder = nil - UIApplication.shared.sendAction(#selector(UIResponder.findFirstResponder(_:)), to: nil, from: nil, for: nil) - return _currentFirstResponder - } - - private weak static var _currentFirstResponder: UIResponder? - - @objc private func findFirstResponder(_: Any) { - UIResponder._currentFirstResponder = self - } - - var globalFrame: CGRect? { - guard let view = self as? UIView else { - return nil - } - - return view.superview?.convert(view.frame, to: nil) - } -} diff --git a/Core/Core/Extensions/UIView+EnclosingScrollView.swift b/Core/Core/Extensions/UIView+EnclosingScrollView.swift deleted file mode 100644 index 05314e334..000000000 --- a/Core/Core/Extensions/UIView+EnclosingScrollView.swift +++ /dev/null @@ -1,18 +0,0 @@ -// - -import UIKit - -extension UIView { - func enclosingScrollView() -> UIScrollView? { - var next: UIView? = self - - repeat { - next = next?.superview - if let scrollview = next as? UIScrollView { - return scrollview - } - } while next != nil - - return nil - } -} diff --git a/Core/Core/Extensions/UrlExtension.swift b/Core/Core/Extensions/UrlExtension.swift deleted file mode 100644 index cb09fdfc2..000000000 --- a/Core/Core/Extensions/UrlExtension.swift +++ /dev/null @@ -1,26 +0,0 @@ -// -// UrlExtension.swift -// Core -// -// Created by  Stepanok Ivan on 10.11.2022. -// - -import Foundation -import SwiftUI - -public extension URL { - func isImage() -> Bool { - if self.pathExtension == "jpg" - || self.pathExtension == "png" - || self.pathExtension == "PNG" - || self.pathExtension == "gif" - || self.pathExtension == "jpeg" - || self.pathExtension == "JPEG" - || self.pathExtension == "JPG" - || self.pathExtension == "bmp" { - return true - } else { - return false - } - } -} diff --git a/Core/Core/Extensions/ViewExtension.swift b/Core/Core/Extensions/ViewExtension.swift index 8f6320658..cec65c994 100644 --- a/Core/Core/Extensions/ViewExtension.swift +++ b/Core/Core/Extensions/ViewExtension.swift @@ -90,55 +90,6 @@ public extension View { .padding(.horizontal, 48) } - func frameLimit(width: CGFloat? = nil) -> some View { - modifier(ReadabilityModifier(width: width)) - } - - @ViewBuilder - func adaptiveHStack( - spacing: CGFloat = 0, - currentOrientation: UIInterfaceOrientation, - @ViewBuilder content: () -> Content - ) -> some View { - if currentOrientation.isLandscape && UIDevice.current.userInterfaceIdiom != .pad { - VStack(alignment: .center, spacing: spacing, content: content) - } else if currentOrientation.isPortrait && UIDevice.current.userInterfaceIdiom != .pad { - HStack(spacing: spacing, content: content) - } else if UIDevice.current.userInterfaceIdiom != .phone { - HStack(spacing: spacing, content: content) - } - } - - @ViewBuilder - func adaptiveStack( - spacing: CGFloat = 0, - isHorizontal: Bool, - @ViewBuilder content: () -> Content - ) -> some View { - if isHorizontal, UIDevice.current.userInterfaceIdiom != .pad { - HStack(spacing: spacing, content: content) - } else { - VStack(alignment: .center, spacing: spacing, content: content) - } - } - - @ViewBuilder - func adaptiveNavigationStack( - spacing: CGFloat = 0, - isHorizontal: Bool, - @ViewBuilder content: () -> Content - ) -> some View { - if UIDevice.current.userInterfaceIdiom == .pad { - HStack(spacing: spacing, content: content) - } else { - if isHorizontal { - HStack(alignment: .top, spacing: spacing, content: content) - } else { - VStack(alignment: .center, spacing: spacing, content: content) - } - } - } - func roundedBackground( _ color: Color = Theme.Colors.background, strokeColor: Color = Theme.Colors.backgroundStroke, @@ -178,73 +129,6 @@ public extension View { } } } - - func onRightSwipeGesture(perform action: @escaping () -> Void) -> some View { - self.gesture( - DragGesture(minimumDistance: 20, coordinateSpace: .local) - .onEnded { value in - if value.translation.width > 0 && abs(value.translation.height) < 50 { - action() - } - } - ) - } - - func onBackground(_ f: @escaping () -> Void) -> some View { - self.onReceive( - NotificationCenter.default.publisher(for: UIApplication.willResignActiveNotification), - perform: { _ in f() } - ) - } - - func onForeground(_ f: @escaping () -> Void) -> some View { - self.onReceive( - NotificationCenter.default.publisher(for: UIApplication.didBecomeActiveNotification), - perform: { _ in f() } - ) - } - - 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) - } - - @ViewBuilder - private func onTapBackgroundContent(enabled: Bool, _ action: @escaping () -> Void) -> some View { - if enabled { - Color.clear - .frame(width: UIScreen.main.bounds.width * 2, height: UIScreen.main.bounds.height * 2) - .contentShape(Rectangle()) - .onTapGesture(perform: action) - } - } - - func onTapBackground(enabled: Bool, _ action: @escaping () -> Void) -> some View { - background( - onTapBackgroundContent(enabled: enabled, action) - ) - } -} - -public extension View { - /// Applies the given transform if the given condition evaluates to `true`. - /// - Parameters: - /// - condition: The condition to evaluate. - /// - transform: The transform to apply to the source `View`. - /// - Returns: Either the original `View` or the modified `View` if the condition is `true`. - @ViewBuilder func `if`(_ condition: Bool, transform: (Self) -> Content) -> some View { - if condition { - transform(self) - } else { - self - } - } } public extension View { @@ -278,22 +162,6 @@ public extension View { } } -private struct FirstAppear: ViewModifier { - let action: () -> Void - - // Use this to only fire your block one time - @State private var hasAppeared = false - - func body(content: Content) -> some View { - // And then, track it here - content.onAppear { - guard !hasAppeared else { return } - hasAppeared = true - action() - } - } -} - public extension Image { func backButtonStyle(topPadding: CGFloat = -10, color: Color = Theme.Colors.accentColor) -> some View { return self @@ -304,14 +172,3 @@ public extension Image { .backViewStyle(topPadding: topPadding) } } - -public extension EnvironmentValues { - var isHorizontal: Bool { - if UIDevice.current.userInterfaceIdiom != .pad { - if let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene { - return windowScene.windows.first?.windowScene?.interfaceOrientation.isLandscape ?? true - } - } - return false - } -} diff --git a/Core/Core/Network/API.swift b/Core/Core/Network/API.swift deleted file mode 100644 index 7e3964b62..000000000 --- a/Core/Core/Network/API.swift +++ /dev/null @@ -1,304 +0,0 @@ -// -// API.swift -// Core -// -// Created by Vladimir Chekyrta on 13.09.2022. -// - -import Foundation -import Alamofire -import WebKit - -public final class API { - - private let session: Alamofire.Session - private let config: ConfigProtocol - - public init(session: Session, config: ConfigProtocol) { - self.session = session - self.config = config - } - - @discardableResult - public func requestData( - _ route: EndPointType - ) async throws -> Data { - switch route.task { - case .request: - return try await callData(route) - case let .requestParameters(parameters, encoding): - return try await callData(route, parameters: parameters, encoding: encoding) - case let .requestCodable(parameters, encoding): - let params = try? parameters?.asDictionary() - return try await callData(route, parameters: params, encoding: encoding) - case .requestCookies: - return try await callCookies(route) - case .upload: - throw APIError.invalidRequest - } - } - - public func request( - _ route: EndPointType - ) async throws -> HTTPURLResponse { - switch route.task { - case .request: - return try await callResponse(route) - case let .requestParameters(parameters, encoding): - return try await callResponse(route, parameters: parameters, encoding: encoding) - case let .requestCodable(parameters, encoding): - let params = try? parameters?.asDictionary() - return try await callResponse(route, parameters: params, encoding: encoding) - case .requestCookies: - return try await callResponse(route) - case let .upload(data): - return try await uploadData(route, data: data) - } - } - - private func callData( - _ route: EndPointType, - parameters: Parameters? = nil, - encoding: ParameterEncoding = URLEncoding.default - ) async throws -> Data { - var url = config.baseURL - if !route.path.isEmpty { - url = url.appendingPathComponent(route.path) - } - - let result = session.request( - url, - method: route.httpMethod, - parameters: parameters, - encoding: encoding, - headers: route.headers - ).validateResponse().serializingData() - - let latestVersion = await result.response.response?.headers["EDX-APP-LATEST-VERSION"] - - if await result.response.response?.statusCode != 426 { - if let latestVersion = latestVersion { - NotificationCenter.default.post(name: .onActualVersionReceived, object: latestVersion) - } - } - - return try await result.value - - } - - private func callCookies( - _ route: EndPointType, - parameters: Parameters? = nil, - encoding: ParameterEncoding = URLEncoding.default - ) async throws -> Data { - var url = config.baseURL - if !route.path.isEmpty { - url = url.appendingPathComponent(route.path) - } - let response = session.request( - url, - method: route.httpMethod, - parameters: parameters, - encoding: encoding, - headers: route.headers - ) - - let value = try await response.validateResponse().serializingData().value - - parseAndSetCookies(response: response.response) - return value - } - - private func uploadData( - _ route: EndPointType, - data: Data - ) async throws -> HTTPURLResponse { - var url = config.baseURL - if !route.path.isEmpty { - url = url.appendingPathComponent(route.path) - } - - let response = await session.request( - url, - method: route.httpMethod, - encoding: UploadBodyEncoding(body: data), - headers: route.headers - ).validateResponse().serializingResponse(using: .string).response - - if let response = response.response { - return response - } else if let error = response.error { - throw error - } else { - throw APIError.unknown - } - } - - private func parseAndSetCookies(response: HTTPURLResponse?) { - guard let fields = response?.allHeaderFields as? [String: String] else { return } - let url = config.baseURL - let cookies = HTTPCookie.cookies(withResponseHeaderFields: fields, for: url) - HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } - HTTPCookieStorage.shared.setCookies(cookies, for: url, mainDocumentURL: nil) - DispatchQueue.main.async { - let cookies = HTTPCookieStorage.shared.cookies ?? [] - for c in cookies { - WKWebsiteDataStore.default().httpCookieStore.setCookie(c) - } - } - } - - private func callResponse( - _ route: EndPointType, - parameters: Parameters? = nil, - encoding: ParameterEncoding = URLEncoding.default - ) async throws -> HTTPURLResponse { - var url = config.baseURL - if !route.path.isEmpty { - url = url.appendingPathComponent(route.path) - } - let serializer = DataResponseSerializer(emptyResponseCodes: [200, 204, 205]) - - let response = await session.request( - url, - method: route.httpMethod, - parameters: parameters, - encoding: encoding, - headers: route.headers - ).validateResponse().serializingResponse(using: serializer).response - - if let error = response.error { - throw error - } else if let response = response.response { - return response - } else { - throw APIError.unknown - } - } -} - -public enum APIError: Int, LocalizedError { - case unknown = -100 - case emptyData = -200 - case invalidGrant = -300 - case parsingError = -400 - case invalidRequest = -500 - case uploadError = -600 - - public var errorDescription: String? { - switch self { - default: - return nil - } - } - - public var localizedDescription: String { - return errorDescription ?? "" - } -} - -public struct CustomValidationError: LocalizedError { - public let statusCode: Int - public let data: [String: Any]? - - public init(statusCode: Int, data: [String: Any]?) { - self.statusCode = statusCode - self.data = data - } -} - -extension DataRequest { - func validateResponse() -> Self { - return validateStatusCode().validateContentType() - } - - func validateStatusCode() -> Self { - return validate { _, response, data in - switch response.statusCode { - case 200...299: - return .success(()) - case 400...403: - if let data { - if let dataString = String(data: data, encoding: .utf8) { - if dataString.first == "{" && dataString.last == "}" { - let json = try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] - return .failure(CustomValidationError(statusCode: response.statusCode, data: json)) - } else { - let reason: AFError.ResponseValidationFailureReason - = .unacceptableStatusCode(code: response.statusCode) - return .failure(AFError.responseValidationFailed(reason: reason)) - } - } - } - let reason: AFError.ResponseValidationFailureReason = .unacceptableStatusCode(code: response.statusCode) - return .failure(AFError.responseValidationFailed(reason: reason)) - default: - let reason: AFError.ResponseValidationFailureReason = .unacceptableStatusCode(code: response.statusCode) - return .failure(AFError.responseValidationFailed(reason: reason)) - } - } - } - - func validateContentType() -> Self { - let contentTypes: () -> [String] = { [unowned self] in - if let accept = request?.value(forHTTPHeaderField: "Accept") { - return accept.components(separatedBy: ",") - } - return ["*/*"] - } - return validate(contentType: contentTypes()) - } -} - -public struct CustomGetEncoding: ParameterEncoding { - public init() {} - public func encode(_ urlRequest: URLRequestConvertible, with parameters: Parameters?) throws -> URLRequest { - var request = try URLEncoding().encode(urlRequest, with: parameters) - request.url = URL(string: request.url!.absoluteString.replacingOccurrences(of: "%5B%5D=", with: "=")) - return request - } -} - -extension Encodable { - func asDictionary() throws -> [String: Any] { - let data = try JSONEncoder().encode(self) - guard let dictionary = try JSONSerialization.jsonObject( - with: data, - options: .fragmentsAllowed - ) as? [String: Any] else { - throw NSError() - } - return dictionary - } -} - -public extension Data { - func mapResponse(_ decodableType: NewSuccess.Type) throws -> NewSuccess where NewSuccess: Decodable { - do { - let baseResponse = try JSONDecoder().decode(NewSuccess.self, from: self) - - return baseResponse - } catch { - print(error) - throw APIError.parsingError - } - } -} - -public extension Error { - var validationError: CustomValidationError? { - if let afError = self.asAFError, case AFError.responseValidationFailed(let reason) = afError { - if case AFError.ResponseValidationFailureReason.customValidationFailed(let error) = reason { - return error as? CustomValidationError - } - } - return nil - } -} - -// Mark - For testing and SwiftUI preview -#if DEBUG -public extension API { - static let mock: API = .init(session: Alamofire.Session.default, config: ConfigMock()) -} -#endif diff --git a/Core/Core/Network/Alamofire+Error.swift b/Core/Core/Network/Alamofire+Error.swift deleted file mode 100644 index 90068b90a..000000000 --- a/Core/Core/Network/Alamofire+Error.swift +++ /dev/null @@ -1,30 +0,0 @@ -// -// Alamofire+Error.swift -// Core -// -// Created by Vladimir Chekyrta on 14.09.2022. -// - -import Alamofire -import Foundation - -public extension Error { - var isUpdateRequeiredError: Bool { - self.asAFError?.responseCode == 426 - } - - var isInternetError: Bool { - guard let afError = self.asAFError, - let urlError = afError.underlyingError as? URLError else { - return false - } - switch urlError.code { - case .timedOut, .cannotConnectToHost, .networkConnectionLost, - .notConnectedToInternet, .resourceUnavailable, .internationalRoamingOff, - .dataNotAllowed: - return true - default: - return false - } - } -} diff --git a/Core/Core/Network/AuthEndpoint.swift b/Core/Core/Network/AuthEndpoint.swift index 609e4890b..c4f70adc3 100644 --- a/Core/Core/Network/AuthEndpoint.swift +++ b/Core/Core/Network/AuthEndpoint.swift @@ -7,6 +7,7 @@ import Foundation import Alamofire +import OEXFoundation enum AuthEndpoint: EndPointType { case getAccessToken(username: String, password: String, clientId: String, tokenType: String) diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index d21a95d69..d7e277066 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -5,10 +5,11 @@ // Created by  Stepanok Ivan on 08.03.2023. // -import Alamofire import SwiftUI import Combine import ZipArchive +import OEXFoundation +import Alamofire public enum DownloadState: String { case waiting diff --git a/Core/Core/Network/EndPointType.swift b/Core/Core/Network/EndPointType.swift deleted file mode 100644 index f27534429..000000000 --- a/Core/Core/Network/EndPointType.swift +++ /dev/null @@ -1,16 +0,0 @@ -// -// EndPointType.swift -// Core -// -// Created by Vladimir Chekyrta on 13.09.2022. -// - -import Foundation -import Alamofire - -public protocol EndPointType { - var path: String { get } - var httpMethod: HTTPMethod { get } - var headers: HTTPHeaders? { get } - var task: HTTPTask { get } -} diff --git a/Core/Core/Network/HTTPTask.swift b/Core/Core/Network/HTTPTask.swift deleted file mode 100644 index 5e07200f7..000000000 --- a/Core/Core/Network/HTTPTask.swift +++ /dev/null @@ -1,17 +0,0 @@ -// -// HTTPTask.swift -// Core -// -// Created by Vladimir Chekyrta on 13.09.2022. -// - -import Foundation -import Alamofire - -public enum HTTPTask { - case request - case requestCookies - case requestParameters(parameters: Parameters? = nil, encoding: ParameterEncoding) - case requestCodable(parameters: Encodable? = nil, encoding: ParameterEncoding) - case upload(Data) -} diff --git a/Core/Core/Network/HeadersRedirectHandler.swift b/Core/Core/Network/HeadersRedirectHandler.swift deleted file mode 100644 index 3653392bd..000000000 --- a/Core/Core/Network/HeadersRedirectHandler.swift +++ /dev/null @@ -1,33 +0,0 @@ -// -// HeadersRedirectHandler.swift -// Core -// -// Created by Vladimir Chekyrta on 14.09.2022. -// - -import Foundation -import Alamofire - -public class HeadersRedirectHandler: RedirectHandler { - - public init() { - } - - public func task( - _ task: URLSessionTask, - willBeRedirectedTo request: URLRequest, - for response: HTTPURLResponse, - completion: @escaping (URLRequest?) -> Void - ) { - var redirectedRequest = request - - if let originalRequest = task.originalRequest, - let headers = originalRequest.allHTTPHeaderFields { - for (key, value) in headers { - redirectedRequest.setValue(value, forHTTPHeaderField: key) - } - } - - completion(redirectedRequest) - } -} diff --git a/Core/Core/Network/NetworkLogger.swift b/Core/Core/Network/NetworkLogger.swift deleted file mode 100644 index 7335be2b2..000000000 --- a/Core/Core/Network/NetworkLogger.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// NetworkLogger.swift -// Core -// -// Created by Vladimir Chekyrta on 13.09.2022. -// - -import Alamofire -import Foundation - -public class NetworkLogger: EventMonitor { - - public let queue = DispatchQueue(label: "com.raccoongang.networklogger") - - public init() { - } - - public func requestDidResume(_ request: Request) { - print("Request:", request.description) - if let headers = request.request?.headers { - print("Headers:") - print(headers) - print("------") - } - if let body = request.request?.httpBody, let value = String(data: body, encoding: .utf8) { - print("Body:") - print(value) - print("------") - } - } - - public func request(_ request: DataRequest, didParseResponse response: DataResponse) { - guard let data = response.data else { - return - } - guard let responseValue = String(data: data, encoding: .utf8) else { - return - } - print("Response:", request.description) - print(responseValue) - -// if let json = try? JSONSerialization -// .jsonObject(with: data, options: .mutableContainers) { -// print(json) -// } - } - -} diff --git a/Core/Core/Network/OfflineSyncEndpoint.swift b/Core/Core/Network/OfflineSyncEndpoint.swift index ce3f680ac..0db8fdbc6 100644 --- a/Core/Core/Network/OfflineSyncEndpoint.swift +++ b/Core/Core/Network/OfflineSyncEndpoint.swift @@ -7,6 +7,7 @@ import Foundation import Alamofire +import OEXFoundation enum OfflineSyncEndpoint: EndPointType { case submitOfflineProgress(courseID: String, blockID: String, data: String) diff --git a/Core/Core/Network/OfflineSyncManager.swift b/Core/Core/Network/OfflineSyncManager.swift index 6e9cdd30f..295d79bec 100644 --- a/Core/Core/Network/OfflineSyncManager.swift +++ b/Core/Core/Network/OfflineSyncManager.swift @@ -9,6 +9,7 @@ import Foundation import WebKit import Combine import Swinject +import OEXFoundation public protocol OfflineSyncManagerProtocol { func handleMessage(message: WKScriptMessage, blockID: String) diff --git a/Core/Core/Network/UploadBodyEncoding.swift b/Core/Core/Network/UploadBodyEncoding.swift deleted file mode 100644 index bfbc808cd..000000000 --- a/Core/Core/Network/UploadBodyEncoding.swift +++ /dev/null @@ -1,44 +0,0 @@ -// -// UploadBodyEncoding.swift -// Core -// -// Created by  Stepanok Ivan on 29.11.2022. -// - -import Foundation -import Alamofire - -public struct UploadBodyEncoding: ParameterEncoding { - - private var body: Data - - public init(body: Data) { - self.body = body - } - - public func encode( - _ urlRequest: Alamofire.URLRequestConvertible, - with parameters: Alamofire.Parameters? - ) throws -> URLRequest { - var urlRequest = try urlRequest.asURLRequest() - - guard let url = urlRequest.url else { - throw AFError.parameterEncodingFailed(reason: .missingURL) - } - - if var urlComponents = URLComponents(url: url, resolvingAgainstBaseURL: false) { - let percentEncodedQuery = (urlComponents.percentEncodedQuery.map { $0 + "&" } ?? "") - urlComponents.percentEncodedQuery = percentEncodedQuery - urlRequest.url = urlComponents.url - } - - if urlRequest.headers["Content-Type"] == nil { - urlRequest.headers.update(.contentType("image/jpeg")) - } - - urlRequest.httpBody = body - - return urlRequest - } - -} diff --git a/Core/Core/View/Base/BackNavigationButtonViewModel.swift b/Core/Core/View/Base/BackNavigationButtonViewModel.swift index 00c4b5d76..59dfe97a5 100644 --- a/Core/Core/View/Base/BackNavigationButtonViewModel.swift +++ b/Core/Core/View/Base/BackNavigationButtonViewModel.swift @@ -7,6 +7,7 @@ import Swinject import UIKit +import OEXFoundation public protocol BackNavigationProtocol { func getBackMenuItems() -> [BackNavigationMenuItem] diff --git a/Core/CoreTests/Configuration/ConfigTests.swift b/Core/CoreTests/Configuration/ConfigTests.swift index 35a90c2b7..c408e02a5 100644 --- a/Core/CoreTests/Configuration/ConfigTests.swift +++ b/Core/CoreTests/Configuration/ConfigTests.swift @@ -140,11 +140,4 @@ 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/Core/CoreTests/Configuration/FullStoryConfigTests.swift b/Core/CoreTests/Configuration/FullStoryConfigTests.swift deleted file mode 100644 index 092d9a5ee..000000000 --- a/Core/CoreTests/Configuration/FullStoryConfigTests.swift +++ /dev/null @@ -1,57 +0,0 @@ -// -// FullStoryConfigTests.swift -// CoreTests -// -// Created by Saeed Bashir on 6/21/24. -// - -import XCTest -@testable import Core - -class FullStoryConfigTests: XCTestCase { - - func testNoFullStoryConfig() { - let config = Config(properties: [:]) - XCTAssertFalse(config.fullStory.enabled) - } - - func testFullStoryEnabled() { - let configDictionary = [ - "FULLSTORY": [ - "ENABLED": true, - "ORG_ID": "org_id" - ] - ] - - let config = Config(properties: configDictionary) - - XCTAssertTrue(config.fullStory.enabled) - XCTAssertNotNil(config.fullStory.orgID) - } - - func testFullStoryDisabled() { - let configDictionary = [ - "FULLSTORY": [ - "ENABLED": false, - "ORG_ID": "org_id" - ] - ] - - let config = Config(properties: configDictionary) - - XCTAssertFalse(config.fullStory.enabled) - XCTAssertNotNil(config.fullStory.orgID) - } - - func testFullStoryMissingORGID() { - let configDictionary = [ - "FULLSTORY": [ - "ENABLED": true - ] - ] - - let config = Config(properties: configDictionary) - XCTAssertFalse(config.fullStory.enabled) - XCTAssertEqual(config.fullStory.orgID, "") - } -} diff --git a/Core/CoreTests/CoreMock.generated.swift b/Core/CoreTests/CoreMock.generated.swift index 3fbb5c626..261c6be74 100644 --- a/Core/CoreTests/CoreMock.generated.swift +++ b/Core/CoreTests/CoreMock.generated.swift @@ -1517,11 +1517,6 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { } private var __p_branch: (BranchConfig)? - public var segment: SegmentConfig { - get { invocations.append(.p_segment_get); return __p_segment ?? givenGetterValue(.p_segment_get, "ConfigProtocolMock - stub value for segment was not defined") } - } - private var __p_segment: (SegmentConfig)? - public var program: DiscoveryConfig { get { invocations.append(.p_program_get); return __p_program ?? givenGetterValue(.p_program_get, "ConfigProtocolMock - stub value for program was not defined") } } @@ -1532,11 +1527,6 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { } private var __p_URIScheme: (String)? - public var fullStory: FullStoryConfig { - get { invocations.append(.p_fullStory_get); return __p_fullStory ?? givenGetterValue(.p_fullStory_get, "ConfigProtocolMock - stub value for fullStory was not defined") } - } - private var __p_fullStory: (FullStoryConfig)? - @@ -1566,10 +1556,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case p_dashboard_get case p_braze_get case p_branch_get - case p_segment_get case p_program_get case p_URIScheme_get - case p_fullStory_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { case (.p_baseURL_get,.p_baseURL_get): return Matcher.ComparisonResult.match @@ -1595,10 +1583,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case (.p_dashboard_get,.p_dashboard_get): return Matcher.ComparisonResult.match case (.p_braze_get,.p_braze_get): return Matcher.ComparisonResult.match case (.p_branch_get,.p_branch_get): return Matcher.ComparisonResult.match - case (.p_segment_get,.p_segment_get): return Matcher.ComparisonResult.match case (.p_program_get,.p_program_get): return Matcher.ComparisonResult.match case (.p_URIScheme_get,.p_URIScheme_get): return Matcher.ComparisonResult.match - case (.p_fullStory_get,.p_fullStory_get): return Matcher.ComparisonResult.match default: return .none } } @@ -1628,10 +1614,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case .p_dashboard_get: return 0 case .p_braze_get: return 0 case .p_branch_get: return 0 - case .p_segment_get: return 0 case .p_program_get: return 0 case .p_URIScheme_get: return 0 - case .p_fullStory_get: return 0 } } func assertionName() -> String { @@ -1659,10 +1643,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case .p_dashboard_get: return "[get] .dashboard" case .p_braze_get: return "[get] .braze" case .p_branch_get: return "[get] .branch" - case .p_segment_get: return "[get] .segment" case .p_program_get: return "[get] .program" case .p_URIScheme_get: return "[get] .URIScheme" - case .p_fullStory_get: return "[get] .fullStory" } } } @@ -1744,18 +1726,12 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { public static func branch(getter defaultValue: BranchConfig...) -> PropertyStub { return Given(method: .p_branch_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) } - public static func segment(getter defaultValue: SegmentConfig...) -> PropertyStub { - return Given(method: .p_segment_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } public static func program(getter defaultValue: DiscoveryConfig...) -> PropertyStub { return Given(method: .p_program_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) } public static func URIScheme(getter defaultValue: String...) -> PropertyStub { return Given(method: .p_URIScheme_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) } - public static func fullStory(getter defaultValue: FullStoryConfig...) -> PropertyStub { - return Given(method: .p_fullStory_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } } @@ -1785,10 +1761,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { public static var dashboard: Verify { return Verify(method: .p_dashboard_get) } public static var braze: Verify { return Verify(method: .p_braze_get) } public static var branch: Verify { return Verify(method: .p_branch_get) } - public static var segment: Verify { return Verify(method: .p_segment_get) } public static var program: Verify { return Verify(method: .p_program_get) } public static var URIScheme: Verify { return Verify(method: .p_URIScheme_get) } - public static var fullStory: Verify { return Verify(method: .p_fullStory_get) } } public struct Perform { diff --git a/Course/Course.xcodeproj/project.pbxproj b/Course/Course.xcodeproj/project.pbxproj index 81830204f..930494938 100644 --- a/Course/Course.xcodeproj/project.pbxproj +++ b/Course/Course.xcodeproj/project.pbxproj @@ -99,6 +99,9 @@ BAC0E0DE2B32F0F3006B68A9 /* DownloadsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC0E0DD2B32F0F3006B68A9 /* DownloadsViewModel.swift */; }; BAD9CA2D2B2736BB00DE790A /* LessonLineProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA2C2B2736BB00DE790A /* LessonLineProgressView.swift */; }; BAD9CA4A2B2C88E000DE790A /* CourseVideoDownloadBarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA492B2C88E000DE790A /* CourseVideoDownloadBarView.swift */; }; + CE7CAF412CC1563500E0AC9D /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CE7CAF402CC1563500E0AC9D /* OEXFoundation */; }; + CEB1E2732CC14EC400921517 /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CEB1E2722CC14EC400921517 /* OEXFoundation */; }; + CEBCA4342CC13CDE00076589 /* YouTubePlayerKit in Frameworks */ = {isa = PBXBuildFile; productRef = CEBCA4332CC13CDE00076589 /* YouTubePlayerKit */; }; DB205BFB2AE81B1200136EC2 /* CourseDateViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB205BFA2AE81B1200136EC2 /* CourseDateViewModelTests.swift */; }; DB7D6EAC2ADFCAC50036BB13 /* CourseDatesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EAB2ADFCAC40036BB13 /* CourseDatesView.swift */; }; DB7D6EAE2ADFCB4A0036BB13 /* CourseDatesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7D6EAD2ADFCB4A0036BB13 /* CourseDatesViewModel.swift */; }; @@ -114,6 +117,19 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + CE7CAF432CC1563500E0AC9D /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 02228B2E2C221412009A5F28 /* LargestDownloadsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LargestDownloadsView.swift; sourceTree = ""; }; 02280F5D294B4FDA0032823A /* CourseCoreModel.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CourseCoreModel.xcdatamodel; sourceTree = ""; }; @@ -243,6 +259,7 @@ buildActionMask = 2147483647; files = ( 023812E8297AC8EB0087098F /* Course.framework in Frameworks */, + CE7CAF412CC1563500E0AC9D /* OEXFoundation in Frameworks */, B8F50317B6B830A0E520C954 /* Pods_App_Course_CourseTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -253,7 +270,9 @@ files = ( 02B6B3C928E1E68100232911 /* Core.framework in Frameworks */, 197FB8EA8F92F00A8F383D82 /* Pods_App_Course.framework in Frameworks */, + CEBCA4342CC13CDE00076589 /* YouTubePlayerKit in Frameworks */, 02F98A8128F8224200DE94C0 /* Discussion.framework in Frameworks */, + CEB1E2732CC14EC400921517 /* OEXFoundation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -696,6 +715,7 @@ 023812E1297AC8EA0087098F /* Frameworks */, 023812E2297AC8EA0087098F /* Resources */, 92C3B3183886DDECE1CBAC22 /* [CP] Copy Pods Resources */, + CE7CAF432CC1563500E0AC9D /* Embed Frameworks */, ); buildRules = ( ); @@ -756,6 +776,10 @@ uk, ); mainGroup = 0289F8E428E1C3510064F8F3; + packageReferences = ( + CEBCA4322CC13CDE00076589 /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */, + CEB1E2712CC14EC400921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */, + ); productRefGroup = 0289F8EF28E1C3510064F8F3 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -1984,6 +2008,43 @@ }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + CEB1E2712CC14EC400921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/openedx/openedx-app-foundation-ios/"; + requirement = { + kind = exactVersion; + version = 1.0.0; + }; + }; + CEBCA4322CC13CDE00076589 /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SvenTiigi/YouTubePlayerKit"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.9.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + CE7CAF402CC1563500E0AC9D /* OEXFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = CEB1E2712CC14EC400921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */; + productName = OEXFoundation; + }; + CEB1E2722CC14EC400921517 /* OEXFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = CEB1E2712CC14EC400921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */; + productName = OEXFoundation; + }; + CEBCA4332CC13CDE00076589 /* YouTubePlayerKit */ = { + isa = XCSwiftPackageProductDependency; + package = CEBCA4322CC13CDE00076589 /* XCRemoteSwiftPackageReference "YouTubePlayerKit" */; + productName = YouTubePlayerKit; + }; +/* End XCSwiftPackageProductDependency section */ + /* Begin XCVersionGroup section */ 02280F5C294B4FDA0032823A /* CourseCoreModel.xcdatamodeld */ = { isa = XCVersionGroup; diff --git a/Course/Course/Data/CourseRepository.swift b/Course/Course/Data/CourseRepository.swift index 09e86c588..7210a9f76 100644 --- a/Course/Course/Data/CourseRepository.swift +++ b/Course/Course/Data/CourseRepository.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation public protocol CourseRepositoryProtocol { func getCourseBlocks(courseID: String) async throws -> CourseStructure diff --git a/Course/Course/Data/Network/CourseEndpoint.swift b/Course/Course/Data/Network/CourseEndpoint.swift index 323ae5e8d..62531a042 100644 --- a/Course/Course/Data/Network/CourseEndpoint.swift +++ b/Course/Course/Data/Network/CourseEndpoint.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation import Alamofire enum CourseEndpoint: EndPointType { diff --git a/Course/Course/Presentation/Container/CourseContainerViewModel.swift b/Course/Course/Presentation/Container/CourseContainerViewModel.swift index b8ddea494..e156dcb1a 100644 --- a/Course/Course/Presentation/Container/CourseContainerViewModel.swift +++ b/Course/Course/Presentation/Container/CourseContainerViewModel.swift @@ -8,6 +8,7 @@ import Foundation import SwiftUI import Core +import OEXFoundation import Combine public enum CourseTab: Int, CaseIterable, Identifiable { diff --git a/Course/Course/Presentation/CourseAnalytics.swift b/Course/Course/Presentation/CourseAnalytics.swift index ea794754d..28eb5918a 100644 --- a/Course/Course/Presentation/CourseAnalytics.swift +++ b/Course/Course/Presentation/CourseAnalytics.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation public enum EnrollmentMode: String { case audit diff --git a/Course/Course/Presentation/Dates/CourseDatesView.swift b/Course/Course/Presentation/Dates/CourseDatesView.swift index 7283f516d..0bccd3853 100644 --- a/Course/Course/Presentation/Dates/CourseDatesView.swift +++ b/Course/Course/Presentation/Dates/CourseDatesView.swift @@ -8,6 +8,7 @@ import Foundation import SwiftUI import Core +import OEXFoundation import Theme import SwiftUIIntrospect @@ -500,7 +501,7 @@ struct CourseDatesView_Previews: PreviewProvider { config: ConfigMock(), courseID: "", courseName: "", - analytics: CourseAnalyticsMock(), + analytics: CourseAnalyticsMock(), calendarManager: CalendarManagerMock() ) diff --git a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift index be2f67b26..5ec8101c6 100644 --- a/Course/Course/Presentation/Dates/CourseDatesViewModel.swift +++ b/Course/Course/Presentation/Dates/CourseDatesViewModel.swift @@ -8,6 +8,7 @@ import Foundation import Core import SwiftUI +import OEXFoundation public class CourseDatesViewModel: ObservableObject { diff --git a/Course/Course/Presentation/Downloads/DownloadsViewModel.swift b/Course/Course/Presentation/Downloads/DownloadsViewModel.swift index 709ecc402..cf74c6c65 100644 --- a/Course/Course/Presentation/Downloads/DownloadsViewModel.swift +++ b/Course/Course/Presentation/Downloads/DownloadsViewModel.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation import Combine final class DownloadsViewModel: ObservableObject { diff --git a/Course/Course/Presentation/Offline/OfflineView.swift b/Course/Course/Presentation/Offline/OfflineView.swift index 6129b251b..eab6e8331 100644 --- a/Course/Course/Presentation/Offline/OfflineView.swift +++ b/Course/Course/Presentation/Offline/OfflineView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import OEXFoundation import Theme struct OfflineView: View { diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index a5dc918ae..d20c4de00 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import OEXFoundation import Kingfisher import Theme import SwiftUIIntrospect @@ -135,11 +136,9 @@ public struct CourseOutlineView: View { .frameLimit(width: proxy.size.width) } .refreshable { - await withTaskGroup(of: Void.self) { group in - group.addTask { + Task { await viewModel.getCourseBlocks(courseID: courseID, withProgress: false) } - } } .onRightSwipeGesture { viewModel.router.back() diff --git a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift index ff3306072..cdbf81318 100644 --- a/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift +++ b/Course/Course/Presentation/Outline/CourseVertical/CourseVerticalView.swift @@ -6,8 +6,8 @@ // import SwiftUI - import Core +import OEXFoundation import Kingfisher import Theme diff --git a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift index b4812db24..704a7ad64 100644 --- a/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift +++ b/Course/Course/Presentation/Subviews/CourseVideoDownloadBarView/CourseVideoDownloadBarViewModel.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation import Combine final class CourseVideoDownloadBarViewModel: ObservableObject { diff --git a/Course/Course/Presentation/Unit/CourseUnitView.swift b/Course/Course/Presentation/Unit/CourseUnitView.swift index 81736c6fe..2bbdc58cc 100644 --- a/Course/Course/Presentation/Unit/CourseUnitView.swift +++ b/Course/Course/Presentation/Unit/CourseUnitView.swift @@ -8,6 +8,7 @@ import Foundation import SwiftUI import Core +import OEXFoundation import Discussion import Combine import Theme diff --git a/Course/Course/Presentation/Video/VideoPlayerViewModel.swift b/Course/Course/Presentation/Video/VideoPlayerViewModel.swift index 5014596da..c35f9df83 100644 --- a/Course/Course/Presentation/Video/VideoPlayerViewModel.swift +++ b/Course/Course/Presentation/Video/VideoPlayerViewModel.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation import _AVKit_SwiftUI import Combine diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index 9033a7383..b30530b11 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -13,6 +13,7 @@ import Course import Foundation import SwiftUI import Combine +import OEXFoundation // MARK: - AuthInteractorProtocol @@ -1518,11 +1519,6 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { } private var __p_branch: (BranchConfig)? - public var segment: SegmentConfig { - get { invocations.append(.p_segment_get); return __p_segment ?? givenGetterValue(.p_segment_get, "ConfigProtocolMock - stub value for segment was not defined") } - } - private var __p_segment: (SegmentConfig)? - public var program: DiscoveryConfig { get { invocations.append(.p_program_get); return __p_program ?? givenGetterValue(.p_program_get, "ConfigProtocolMock - stub value for program was not defined") } } @@ -1533,11 +1529,6 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { } private var __p_URIScheme: (String)? - public var fullStory: FullStoryConfig { - get { invocations.append(.p_fullStory_get); return __p_fullStory ?? givenGetterValue(.p_fullStory_get, "ConfigProtocolMock - stub value for fullStory was not defined") } - } - private var __p_fullStory: (FullStoryConfig)? - @@ -1567,10 +1558,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case p_dashboard_get case p_braze_get case p_branch_get - case p_segment_get case p_program_get case p_URIScheme_get - case p_fullStory_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { case (.p_baseURL_get,.p_baseURL_get): return Matcher.ComparisonResult.match @@ -1596,10 +1585,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case (.p_dashboard_get,.p_dashboard_get): return Matcher.ComparisonResult.match case (.p_braze_get,.p_braze_get): return Matcher.ComparisonResult.match case (.p_branch_get,.p_branch_get): return Matcher.ComparisonResult.match - case (.p_segment_get,.p_segment_get): return Matcher.ComparisonResult.match case (.p_program_get,.p_program_get): return Matcher.ComparisonResult.match case (.p_URIScheme_get,.p_URIScheme_get): return Matcher.ComparisonResult.match - case (.p_fullStory_get,.p_fullStory_get): return Matcher.ComparisonResult.match default: return .none } } @@ -1629,10 +1616,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case .p_dashboard_get: return 0 case .p_braze_get: return 0 case .p_branch_get: return 0 - case .p_segment_get: return 0 case .p_program_get: return 0 case .p_URIScheme_get: return 0 - case .p_fullStory_get: return 0 } } func assertionName() -> String { @@ -1660,10 +1645,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case .p_dashboard_get: return "[get] .dashboard" case .p_braze_get: return "[get] .braze" case .p_branch_get: return "[get] .branch" - case .p_segment_get: return "[get] .segment" case .p_program_get: return "[get] .program" case .p_URIScheme_get: return "[get] .URIScheme" - case .p_fullStory_get: return "[get] .fullStory" } } } @@ -1745,18 +1728,12 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { public static func branch(getter defaultValue: BranchConfig...) -> PropertyStub { return Given(method: .p_branch_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) } - public static func segment(getter defaultValue: SegmentConfig...) -> PropertyStub { - return Given(method: .p_segment_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } public static func program(getter defaultValue: DiscoveryConfig...) -> PropertyStub { return Given(method: .p_program_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) } public static func URIScheme(getter defaultValue: String...) -> PropertyStub { return Given(method: .p_URIScheme_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) } - public static func fullStory(getter defaultValue: FullStoryConfig...) -> PropertyStub { - return Given(method: .p_fullStory_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } } @@ -1786,10 +1763,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { public static var dashboard: Verify { return Verify(method: .p_dashboard_get) } public static var braze: Verify { return Verify(method: .p_braze_get) } public static var branch: Verify { return Verify(method: .p_branch_get) } - public static var segment: Verify { return Verify(method: .p_segment_get) } public static var program: Verify { return Verify(method: .p_program_get) } public static var URIScheme: Verify { return Verify(method: .p_URIScheme_get) } - public static var fullStory: Verify { return Verify(method: .p_fullStory_get) } } public struct Perform { diff --git a/Course/Mockfile b/Course/Mockfile index 58cd4b263..42d9b4b0e 100644 --- a/Course/Mockfile +++ b/Course/Mockfile @@ -14,4 +14,5 @@ unit.tests.mock: - Course - Foundation - SwiftUI - - Combine \ No newline at end of file + - Combine + - OEXFoundation \ No newline at end of file diff --git a/Dashboard/Dashboard.xcodeproj/project.pbxproj b/Dashboard/Dashboard.xcodeproj/project.pbxproj index d6aa32efb..5688cafb4 100644 --- a/Dashboard/Dashboard.xcodeproj/project.pbxproj +++ b/Dashboard/Dashboard.xcodeproj/project.pbxproj @@ -38,6 +38,7 @@ 9AD4A6A1AAF97092CF457FE2 /* Pods_App_Dashboard_DashboardTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 22905947A936093AD23D4CF8 /* Pods_App_Dashboard_DashboardTests.framework */; }; CE1735062CD2552A00F9606A /* PrimaryCourseDashboardViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1735052CD2552A00F9606A /* PrimaryCourseDashboardViewModelTests.swift */; }; CE17350A2CD26CB500F9606A /* AllCoursesViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1735092CD26CB500F9606A /* AllCoursesViewModelTests.swift */; }; + CEB1E26D2CC14E9B00921517 /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CEB1E26C2CC14E9B00921517 /* OEXFoundation */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -116,6 +117,7 @@ buildActionMask = 2147483647; files = ( 027DB33F28D8E605002B6862 /* Core.framework in Frameworks */, + CEB1E26D2CC14E9B00921517 /* OEXFoundation in Frameworks */, 214DA1AADABC7BF4FB8EA1D7 /* Pods_App_Dashboard.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -372,6 +374,9 @@ uk, ); mainGroup = 02EF39DD28D89F560058F6BD; + packageReferences = ( + CEB1E26B2CC14E9B00921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */, + ); productRefGroup = 02EF39E828D89F560058F6BD /* Products */; projectDirPath = ""; projectRoot = ""; @@ -1528,6 +1533,25 @@ }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + CEB1E26B2CC14E9B00921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/openedx/openedx-app-foundation-ios/"; + requirement = { + kind = exactVersion; + version = 1.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + CEB1E26C2CC14E9B00921517 /* OEXFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = CEB1E26B2CC14E9B00921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */; + productName = OEXFoundation; + }; +/* End XCSwiftPackageProductDependency section */ + /* Begin XCVersionGroup section */ 02A48B16295ACE200033D5E0 /* DashboardCoreModel.xcdatamodeld */ = { isa = XCVersionGroup; diff --git a/Dashboard/Dashboard/Data/DashboardRepository.swift b/Dashboard/Dashboard/Data/DashboardRepository.swift index 00fe98df7..61f9e3f41 100644 --- a/Dashboard/Dashboard/Data/DashboardRepository.swift +++ b/Dashboard/Dashboard/Data/DashboardRepository.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation public protocol DashboardRepositoryProtocol { func getEnrollments(page: Int) async throws -> [CourseItem] diff --git a/Dashboard/Dashboard/Data/Network/DashboardEndpoint.swift b/Dashboard/Dashboard/Data/Network/DashboardEndpoint.swift index 59f379215..cfc946d95 100644 --- a/Dashboard/Dashboard/Data/Network/DashboardEndpoint.swift +++ b/Dashboard/Dashboard/Data/Network/DashboardEndpoint.swift @@ -8,6 +8,8 @@ import Foundation import Core import Alamofire +import OEXFoundation +import UIKit enum DashboardEndpoint: EndPointType { case getEnrollments(username: String, page: Int) diff --git a/Dashboard/Dashboard/Presentation/AllCoursesView.swift b/Dashboard/Dashboard/Presentation/AllCoursesView.swift index 95671a487..bdd6a6b77 100644 --- a/Dashboard/Dashboard/Presentation/AllCoursesView.swift +++ b/Dashboard/Dashboard/Presentation/AllCoursesView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import OEXFoundation import Theme public struct AllCoursesView: View { diff --git a/Dashboard/Dashboard/Presentation/ListDashboardView.swift b/Dashboard/Dashboard/Presentation/ListDashboardView.swift index d8f356e82..d402ce41c 100644 --- a/Dashboard/Dashboard/Presentation/ListDashboardView.swift +++ b/Dashboard/Dashboard/Presentation/ListDashboardView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import OEXFoundation import Theme public struct ListDashboardView: View { diff --git a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift index 8b1a97e30..718b0ceb5 100644 --- a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift +++ b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import OEXFoundation import Theme import Swinject diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index f0dcb32d9..975fac8b3 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -13,6 +13,7 @@ import Dashboard import Foundation import SwiftUI import Combine +import OEXFoundation // MARK: - AuthInteractorProtocol @@ -1518,11 +1519,6 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { } private var __p_branch: (BranchConfig)? - public var segment: SegmentConfig { - get { invocations.append(.p_segment_get); return __p_segment ?? givenGetterValue(.p_segment_get, "ConfigProtocolMock - stub value for segment was not defined") } - } - private var __p_segment: (SegmentConfig)? - public var program: DiscoveryConfig { get { invocations.append(.p_program_get); return __p_program ?? givenGetterValue(.p_program_get, "ConfigProtocolMock - stub value for program was not defined") } } @@ -1533,11 +1529,6 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { } private var __p_URIScheme: (String)? - public var fullStory: FullStoryConfig { - get { invocations.append(.p_fullStory_get); return __p_fullStory ?? givenGetterValue(.p_fullStory_get, "ConfigProtocolMock - stub value for fullStory was not defined") } - } - private var __p_fullStory: (FullStoryConfig)? - @@ -1567,10 +1558,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case p_dashboard_get case p_braze_get case p_branch_get - case p_segment_get case p_program_get case p_URIScheme_get - case p_fullStory_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { case (.p_baseURL_get,.p_baseURL_get): return Matcher.ComparisonResult.match @@ -1596,10 +1585,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case (.p_dashboard_get,.p_dashboard_get): return Matcher.ComparisonResult.match case (.p_braze_get,.p_braze_get): return Matcher.ComparisonResult.match case (.p_branch_get,.p_branch_get): return Matcher.ComparisonResult.match - case (.p_segment_get,.p_segment_get): return Matcher.ComparisonResult.match case (.p_program_get,.p_program_get): return Matcher.ComparisonResult.match case (.p_URIScheme_get,.p_URIScheme_get): return Matcher.ComparisonResult.match - case (.p_fullStory_get,.p_fullStory_get): return Matcher.ComparisonResult.match default: return .none } } @@ -1629,10 +1616,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case .p_dashboard_get: return 0 case .p_braze_get: return 0 case .p_branch_get: return 0 - case .p_segment_get: return 0 case .p_program_get: return 0 case .p_URIScheme_get: return 0 - case .p_fullStory_get: return 0 } } func assertionName() -> String { @@ -1660,10 +1645,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case .p_dashboard_get: return "[get] .dashboard" case .p_braze_get: return "[get] .braze" case .p_branch_get: return "[get] .branch" - case .p_segment_get: return "[get] .segment" case .p_program_get: return "[get] .program" case .p_URIScheme_get: return "[get] .URIScheme" - case .p_fullStory_get: return "[get] .fullStory" } } } @@ -1745,18 +1728,12 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { public static func branch(getter defaultValue: BranchConfig...) -> PropertyStub { return Given(method: .p_branch_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) } - public static func segment(getter defaultValue: SegmentConfig...) -> PropertyStub { - return Given(method: .p_segment_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } public static func program(getter defaultValue: DiscoveryConfig...) -> PropertyStub { return Given(method: .p_program_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) } public static func URIScheme(getter defaultValue: String...) -> PropertyStub { return Given(method: .p_URIScheme_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) } - public static func fullStory(getter defaultValue: FullStoryConfig...) -> PropertyStub { - return Given(method: .p_fullStory_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } } @@ -1786,10 +1763,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { public static var dashboard: Verify { return Verify(method: .p_dashboard_get) } public static var braze: Verify { return Verify(method: .p_braze_get) } public static var branch: Verify { return Verify(method: .p_branch_get) } - public static var segment: Verify { return Verify(method: .p_segment_get) } public static var program: Verify { return Verify(method: .p_program_get) } public static var URIScheme: Verify { return Verify(method: .p_URIScheme_get) } - public static var fullStory: Verify { return Verify(method: .p_fullStory_get) } } public struct Perform { diff --git a/Dashboard/Mockfile b/Dashboard/Mockfile index f747b41e0..276791466 100644 --- a/Dashboard/Mockfile +++ b/Dashboard/Mockfile @@ -14,4 +14,5 @@ unit.tests.mock: - Dashboard - Foundation - SwiftUI - - Combine \ No newline at end of file + - Combine + - OEXFoundation \ No newline at end of file diff --git a/Discovery/Discovery.xcodeproj/project.pbxproj b/Discovery/Discovery.xcodeproj/project.pbxproj index 9ed79ac53..8c3d40080 100644 --- a/Discovery/Discovery.xcodeproj/project.pbxproj +++ b/Discovery/Discovery.xcodeproj/project.pbxproj @@ -28,6 +28,8 @@ 1402A0CA2B61012F00A0A00B /* ProgramWebviewViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1402A0C82B61012F00A0A00B /* ProgramWebviewViewModel.swift */; }; 63C6E9CBBF5E33B8B9B4DFEC /* Pods_App_Discovery_DiscoveryUnitTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 780FC373E1D479E58870BD85 /* Pods_App_Discovery_DiscoveryUnitTests.framework */; }; 9F47BCC672941A9854404EC7 /* Pods_App_Discovery.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 919E55130969D91EF03C4C0B /* Pods_App_Discovery.framework */; }; + CE7CAF312CC155FD00E0AC9D /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CE7CAF302CC155FD00E0AC9D /* OEXFoundation */; }; + CEB1E26A2CC14E7900921517 /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CEB1E2692CC14E7900921517 /* OEXFoundation */; }; CFC8494C299A66080055E497 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = CFC8494E299A66080055E497 /* Localizable.stringsdict */; }; CFC84950299BE52C0055E497 /* SearchViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFC8494F299BE52C0055E497 /* SearchViewModelTests.swift */; }; E0B9F69C2B4D57F800168366 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0B9F6962B4D57F800168366 /* SearchView.swift */; }; @@ -55,6 +57,19 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + CE7CAF332CC155FE00E0AC9D /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 022D04872976D7E100E0059B /* DiscoveryUnitTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = DiscoveryUnitTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 022D04892976D7E100E0059B /* DiscoveryViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryViewModelTests.swift; sourceTree = ""; }; @@ -121,6 +136,7 @@ buildActionMask = 2147483647; files = ( 022D048B2976D7E100E0059B /* Discovery.framework in Frameworks */, + CE7CAF312CC155FD00E0AC9D /* OEXFoundation in Frameworks */, 63C6E9CBBF5E33B8B9B4DFEC /* Pods_App_Discovery_DiscoveryUnitTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -130,6 +146,7 @@ buildActionMask = 2147483647; files = ( 072787AD28D34D15002E9142 /* Core.framework in Frameworks */, + CEB1E26A2CC14E7900921517 /* OEXFoundation in Frameworks */, 9F47BCC672941A9854404EC7 /* Pods_App_Discovery.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -358,6 +375,7 @@ 022D04842976D7E100E0059B /* Frameworks */, 022D04852976D7E100E0059B /* Resources */, 7A53F60C849FA0F910D22A82 /* [CP] Copy Pods Resources */, + CE7CAF332CC155FE00E0AC9D /* Embed Frameworks */, ); buildRules = ( ); @@ -418,6 +436,9 @@ uk, ); mainGroup = 0727878F28D34C03002E9142; + packageReferences = ( + CEB1E2682CC14E7900921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */, + ); productRefGroup = 0727879A28D34C03002E9142 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -1596,6 +1617,30 @@ }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + CEB1E2682CC14E7900921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/openedx/openedx-app-foundation-ios/"; + requirement = { + kind = exactVersion; + version = 1.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + CE7CAF302CC155FD00E0AC9D /* OEXFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = CEB1E2682CC14E7900921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */; + productName = OEXFoundation; + }; + CEB1E2692CC14E7900921517 /* OEXFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = CEB1E2682CC14E7900921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */; + productName = OEXFoundation; + }; +/* End XCSwiftPackageProductDependency section */ + /* Begin XCVersionGroup section */ 0297373E2949FB070051696B /* DiscoveryCoreModel.xcdatamodeld */ = { isa = XCVersionGroup; diff --git a/Discovery/Discovery/Data/DiscoveryRepository.swift b/Discovery/Discovery/Data/DiscoveryRepository.swift index fd2121693..aa0b71978 100644 --- a/Discovery/Discovery/Data/DiscoveryRepository.swift +++ b/Discovery/Discovery/Data/DiscoveryRepository.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation import CoreData import Alamofire diff --git a/Discovery/Discovery/Data/Network/DiscoveryEndpoint.swift b/Discovery/Discovery/Data/Network/DiscoveryEndpoint.swift index 957f03112..2d111b847 100644 --- a/Discovery/Discovery/Data/Network/DiscoveryEndpoint.swift +++ b/Discovery/Discovery/Data/Network/DiscoveryEndpoint.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation import Alamofire enum DiscoveryEndpoint: EndPointType { diff --git a/Discovery/Discovery/Presentation/DiscoveryAnalytics.swift b/Discovery/Discovery/Presentation/DiscoveryAnalytics.swift index db2119038..d5c722bd3 100644 --- a/Discovery/Discovery/Presentation/DiscoveryAnalytics.swift +++ b/Discovery/Discovery/Presentation/DiscoveryAnalytics.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation //sourcery: AutoMockable public protocol DiscoveryAnalytics { diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift index d5bc80704..10c03ab40 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import OEXFoundation import Kingfisher import WebKit import Theme diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift index 735abc4fb..b92770c28 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/DiscoveryView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import OEXFoundation import Theme public struct DiscoveryView: View { diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift index 531a7db3e..b2f8ef371 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/SearchView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import OEXFoundation import Theme public struct SearchView: View { diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryURIDetails.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryURIDetails.swift index 2fa9071bd..08310d9b8 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryURIDetails.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryURIDetails.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation // Define your uri scheme public enum URIString: String { diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift index d67351713..14f5395e9 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI import Theme import Core +import OEXFoundation public enum DiscoveryWebviewType: Equatable { case discovery diff --git a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift index 52a9ee7de..5f5078238 100644 --- a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift +++ b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift @@ -9,6 +9,7 @@ import Foundation import SwiftUI import Theme import Core +import OEXFoundation public enum ProgramViewType: String, Equatable { case program diff --git a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index 21cd06ec0..285c4e619 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -13,6 +13,7 @@ import Discovery import Foundation import SwiftUI import Combine +import OEXFoundation // MARK: - AuthInteractorProtocol @@ -1518,11 +1519,6 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { } private var __p_branch: (BranchConfig)? - public var segment: SegmentConfig { - get { invocations.append(.p_segment_get); return __p_segment ?? givenGetterValue(.p_segment_get, "ConfigProtocolMock - stub value for segment was not defined") } - } - private var __p_segment: (SegmentConfig)? - public var program: DiscoveryConfig { get { invocations.append(.p_program_get); return __p_program ?? givenGetterValue(.p_program_get, "ConfigProtocolMock - stub value for program was not defined") } } @@ -1533,11 +1529,6 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { } private var __p_URIScheme: (String)? - public var fullStory: FullStoryConfig { - get { invocations.append(.p_fullStory_get); return __p_fullStory ?? givenGetterValue(.p_fullStory_get, "ConfigProtocolMock - stub value for fullStory was not defined") } - } - private var __p_fullStory: (FullStoryConfig)? - @@ -1567,10 +1558,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case p_dashboard_get case p_braze_get case p_branch_get - case p_segment_get case p_program_get case p_URIScheme_get - case p_fullStory_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { case (.p_baseURL_get,.p_baseURL_get): return Matcher.ComparisonResult.match @@ -1596,10 +1585,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case (.p_dashboard_get,.p_dashboard_get): return Matcher.ComparisonResult.match case (.p_braze_get,.p_braze_get): return Matcher.ComparisonResult.match case (.p_branch_get,.p_branch_get): return Matcher.ComparisonResult.match - case (.p_segment_get,.p_segment_get): return Matcher.ComparisonResult.match case (.p_program_get,.p_program_get): return Matcher.ComparisonResult.match case (.p_URIScheme_get,.p_URIScheme_get): return Matcher.ComparisonResult.match - case (.p_fullStory_get,.p_fullStory_get): return Matcher.ComparisonResult.match default: return .none } } @@ -1629,10 +1616,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case .p_dashboard_get: return 0 case .p_braze_get: return 0 case .p_branch_get: return 0 - case .p_segment_get: return 0 case .p_program_get: return 0 case .p_URIScheme_get: return 0 - case .p_fullStory_get: return 0 } } func assertionName() -> String { @@ -1660,10 +1645,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case .p_dashboard_get: return "[get] .dashboard" case .p_braze_get: return "[get] .braze" case .p_branch_get: return "[get] .branch" - case .p_segment_get: return "[get] .segment" case .p_program_get: return "[get] .program" case .p_URIScheme_get: return "[get] .URIScheme" - case .p_fullStory_get: return "[get] .fullStory" } } } @@ -1745,18 +1728,12 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { public static func branch(getter defaultValue: BranchConfig...) -> PropertyStub { return Given(method: .p_branch_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) } - public static func segment(getter defaultValue: SegmentConfig...) -> PropertyStub { - return Given(method: .p_segment_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } public static func program(getter defaultValue: DiscoveryConfig...) -> PropertyStub { return Given(method: .p_program_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) } public static func URIScheme(getter defaultValue: String...) -> PropertyStub { return Given(method: .p_URIScheme_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) } - public static func fullStory(getter defaultValue: FullStoryConfig...) -> PropertyStub { - return Given(method: .p_fullStory_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } } @@ -1786,10 +1763,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { public static var dashboard: Verify { return Verify(method: .p_dashboard_get) } public static var braze: Verify { return Verify(method: .p_braze_get) } public static var branch: Verify { return Verify(method: .p_branch_get) } - public static var segment: Verify { return Verify(method: .p_segment_get) } public static var program: Verify { return Verify(method: .p_program_get) } public static var URIScheme: Verify { return Verify(method: .p_URIScheme_get) } - public static var fullStory: Verify { return Verify(method: .p_fullStory_get) } } public struct Perform { diff --git a/Discovery/Mockfile b/Discovery/Mockfile index 638dccd32..dcd8b112b 100644 --- a/Discovery/Mockfile +++ b/Discovery/Mockfile @@ -14,4 +14,5 @@ unit.tests.mock: - Discovery - Foundation - SwiftUI - - Combine \ No newline at end of file + - Combine + - OEXFoundation \ No newline at end of file diff --git a/Discussion/Discussion.xcodeproj/project.pbxproj b/Discussion/Discussion.xcodeproj/project.pbxproj index a1b57703d..c0ee0a64c 100644 --- a/Discussion/Discussion.xcodeproj/project.pbxproj +++ b/Discussion/Discussion.xcodeproj/project.pbxproj @@ -60,6 +60,8 @@ 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 */; }; + CE7CAF472CC1566D00E0AC9D /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CE7CAF462CC1566D00E0AC9D /* OEXFoundation */; }; + CEB1E2762CC14ED700921517 /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CEB1E2752CC14ED700921517 /* OEXFoundation */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -72,6 +74,29 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + CE7CAF452CC1564E00E0AC9D /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; + CE7CAF492CC1566D00E0AC9D /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 0201771B29883E96003AC5EF /* ThreadViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadViewModelTests.swift; sourceTree = ""; }; 020767652989393200B976DE /* ResponsesViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ResponsesViewModelTests.swift; sourceTree = ""; }; @@ -152,6 +177,7 @@ buildActionMask = 2147483647; files = ( 0218195928F7347200202564 /* Core.framework in Frameworks */, + CEB1E2762CC14ED700921517 /* OEXFoundation in Frameworks */, 7527943BE0D66C33B167A41A /* Pods_App_Discussion.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -161,6 +187,7 @@ buildActionMask = 2147483647; files = ( 0240D8D32987FE1F003CFE50 /* Discussion.framework in Frameworks */, + CE7CAF472CC1566D00E0AC9D /* OEXFoundation in Frameworks */, 9FC0EF907C0334E383C300C4 /* Pods_App_Discussion_DiscussionTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -485,6 +512,7 @@ 0218194928F7344A00202564 /* Sources */, 0218194A28F7344A00202564 /* Frameworks */, 0218194B28F7344A00202564 /* Resources */, + CE7CAF452CC1564E00E0AC9D /* Embed Frameworks */, ); buildRules = ( ); @@ -504,6 +532,7 @@ 0240D8CC2987FE1F003CFE50 /* Frameworks */, 0240D8CD2987FE1F003CFE50 /* Resources */, 3607DC97DFD36C230ED0AB74 /* [CP] Copy Pods Resources */, + CE7CAF492CC1566D00E0AC9D /* Embed Frameworks */, ); buildRules = ( ); @@ -544,6 +573,9 @@ uk, ); mainGroup = 0218194328F7344A00202564; + packageReferences = ( + CEB1E2742CC14ED700921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */, + ); productRefGroup = 0218194E28F7344A00202564 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -1721,6 +1753,30 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + CEB1E2742CC14ED700921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/openedx/openedx-app-foundation-ios/"; + requirement = { + kind = exactVersion; + version = 1.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + CE7CAF462CC1566D00E0AC9D /* OEXFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = CEB1E2742CC14ED700921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */; + productName = OEXFoundation; + }; + CEB1E2752CC14ED700921517 /* OEXFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = CEB1E2742CC14ED700921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */; + productName = OEXFoundation; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 0218194428F7344A00202564 /* Project object */; } diff --git a/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift b/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift index 1d433aa9b..efa48f022 100644 --- a/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift +++ b/Discussion/Discussion/Data/Network/DiscussionEndpoint.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation import Alamofire enum DiscussionEndpoint: EndPointType { diff --git a/Discussion/Discussion/Data/Network/DiscussionRepository.swift b/Discussion/Discussion/Data/Network/DiscussionRepository.swift index 661ae603f..ce8b8170a 100644 --- a/Discussion/Discussion/Data/Network/DiscussionRepository.swift +++ b/Discussion/Discussion/Data/Network/DiscussionRepository.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation import Combine public protocol DiscussionRepositoryProtocol { diff --git a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift index 6118d2172..99c677cd2 100644 --- a/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift +++ b/Discussion/Discussion/Presentation/Comments/Responses/ResponsesView.swift @@ -9,6 +9,7 @@ import SwiftUI import Core import Combine import Theme +import OEXFoundation public struct ResponsesView: View { diff --git a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift index 0dbc40a3b..6b6d7a689 100644 --- a/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift +++ b/Discussion/Discussion/Presentation/Comments/Thread/ThreadView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import OEXFoundation import Theme public struct ThreadView: View { diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift index 440666e85..e92727f89 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionSearchTopicsView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import OEXFoundation import Theme public struct DiscussionSearchTopicsView: View { diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index e3a6b6361..dbe21843f 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -13,6 +13,7 @@ import Discussion import Foundation import SwiftUI import Combine +import OEXFoundation // MARK: - AuthInteractorProtocol @@ -1518,11 +1519,6 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { } private var __p_branch: (BranchConfig)? - public var segment: SegmentConfig { - get { invocations.append(.p_segment_get); return __p_segment ?? givenGetterValue(.p_segment_get, "ConfigProtocolMock - stub value for segment was not defined") } - } - private var __p_segment: (SegmentConfig)? - public var program: DiscoveryConfig { get { invocations.append(.p_program_get); return __p_program ?? givenGetterValue(.p_program_get, "ConfigProtocolMock - stub value for program was not defined") } } @@ -1533,11 +1529,6 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { } private var __p_URIScheme: (String)? - public var fullStory: FullStoryConfig { - get { invocations.append(.p_fullStory_get); return __p_fullStory ?? givenGetterValue(.p_fullStory_get, "ConfigProtocolMock - stub value for fullStory was not defined") } - } - private var __p_fullStory: (FullStoryConfig)? - @@ -1567,10 +1558,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case p_dashboard_get case p_braze_get case p_branch_get - case p_segment_get case p_program_get case p_URIScheme_get - case p_fullStory_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { case (.p_baseURL_get,.p_baseURL_get): return Matcher.ComparisonResult.match @@ -1596,10 +1585,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case (.p_dashboard_get,.p_dashboard_get): return Matcher.ComparisonResult.match case (.p_braze_get,.p_braze_get): return Matcher.ComparisonResult.match case (.p_branch_get,.p_branch_get): return Matcher.ComparisonResult.match - case (.p_segment_get,.p_segment_get): return Matcher.ComparisonResult.match case (.p_program_get,.p_program_get): return Matcher.ComparisonResult.match case (.p_URIScheme_get,.p_URIScheme_get): return Matcher.ComparisonResult.match - case (.p_fullStory_get,.p_fullStory_get): return Matcher.ComparisonResult.match default: return .none } } @@ -1629,10 +1616,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case .p_dashboard_get: return 0 case .p_braze_get: return 0 case .p_branch_get: return 0 - case .p_segment_get: return 0 case .p_program_get: return 0 case .p_URIScheme_get: return 0 - case .p_fullStory_get: return 0 } } func assertionName() -> String { @@ -1660,10 +1645,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case .p_dashboard_get: return "[get] .dashboard" case .p_braze_get: return "[get] .braze" case .p_branch_get: return "[get] .branch" - case .p_segment_get: return "[get] .segment" case .p_program_get: return "[get] .program" case .p_URIScheme_get: return "[get] .URIScheme" - case .p_fullStory_get: return "[get] .fullStory" } } } @@ -1745,18 +1728,12 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { public static func branch(getter defaultValue: BranchConfig...) -> PropertyStub { return Given(method: .p_branch_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) } - public static func segment(getter defaultValue: SegmentConfig...) -> PropertyStub { - return Given(method: .p_segment_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } public static func program(getter defaultValue: DiscoveryConfig...) -> PropertyStub { return Given(method: .p_program_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) } public static func URIScheme(getter defaultValue: String...) -> PropertyStub { return Given(method: .p_URIScheme_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) } - public static func fullStory(getter defaultValue: FullStoryConfig...) -> PropertyStub { - return Given(method: .p_fullStory_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } } @@ -1786,10 +1763,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { public static var dashboard: Verify { return Verify(method: .p_dashboard_get) } public static var braze: Verify { return Verify(method: .p_braze_get) } public static var branch: Verify { return Verify(method: .p_branch_get) } - public static var segment: Verify { return Verify(method: .p_segment_get) } public static var program: Verify { return Verify(method: .p_program_get) } public static var URIScheme: Verify { return Verify(method: .p_URIScheme_get) } - public static var fullStory: Verify { return Verify(method: .p_fullStory_get) } } public struct Perform { diff --git a/Discussion/Mockfile b/Discussion/Mockfile index dc4c39594..b7eb63e2f 100644 --- a/Discussion/Mockfile +++ b/Discussion/Mockfile @@ -14,4 +14,5 @@ unit.tests.mock: - Discussion - Foundation - SwiftUI - - Combine \ No newline at end of file + - Combine + - OEXFoundation \ No newline at end of file diff --git a/OpenEdX.xcodeproj/project.pbxproj b/OpenEdX.xcodeproj/project.pbxproj index 0d103570a..464e642a1 100644 --- a/OpenEdX.xcodeproj/project.pbxproj +++ b/OpenEdX.xcodeproj/project.pbxproj @@ -41,34 +41,29 @@ 0770DE4C28D0A462006D8A5D /* Authorization.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = 0770DE4A28D0A462006D8A5D /* Authorization.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 0770DE5028D0A707006D8A5D /* NetworkAssembly.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0770DE4F28D0A707006D8A5D /* NetworkAssembly.swift */; }; 0770DE6428D0BCC7006D8A5D /* Localizable.strings in Resources */ = {isa = PBXBuildFile; fileRef = 0770DE6628D0BCC7006D8A5D /* Localizable.strings */; }; - 0780ABE32BFBA2E40093A4A6 /* FirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = 0780ABE22BFBA2E40093A4A6 /* FirebaseAnalytics */; }; - 0780ABE52BFBA2E40093A4A6 /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = 0780ABE42BFBA2E40093A4A6 /* FirebaseMessaging */; }; 0780ABE82BFCA1530093A4A6 /* NotificationsEndpoints.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0780ABE72BFCA1530093A4A6 /* NotificationsEndpoints.swift */; }; 07A7D78F28F5C9060000BE81 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 07A7D78E28F5C9060000BE81 /* Core.framework */; }; 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 */; }; 149FF39E2B9F1AB50034B33F /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 149FF39C2B9F1AB50034B33F /* LaunchScreen.storyboard */; }; - 14D912D32C25483F0077CCCE /* FullStory in Frameworks */ = {isa = PBXBuildFile; productRef = 14D912D22C25483F0077CCCE /* FullStory */; }; - 14D912D72C2551F60077CCCE /* FullStoryAnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14D912D62C2551F60077CCCE /* FullStoryAnalyticsService.swift */; }; 1924EDE8164C7AB17AD4946B /* Pods_App_OpenEdX.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = FC49621A0942E5EE74BDC895 /* 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 */; }; - 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 */; }; - A59702292B83C87900CA064C /* FirebaseAnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A59702282B83C87900CA064C /* FirebaseAnalyticsService.swift */; }; - A5BD3E302B83B0F7006A8983 /* SegmentFirebase in Frameworks */ = {isa = PBXBuildFile; productRef = A5BD3E2F2B83B0F7006A8983 /* SegmentFirebase */; }; - A5C10D8F2B861A70008E864D /* SegmentAnalyticsService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5C10D8E2B861A70008E864D /* SegmentAnalyticsService.swift */; }; - BA3042792B1F7147009B64B7 /* MSAL in Frameworks */ = {isa = PBXBuildFile; productRef = BA3042782B1F7147009B64B7 /* MSAL */; }; BA7468762B96201D00793145 /* DeepLinkRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA7468752B96201D00793145 /* DeepLinkRouter.swift */; }; + CE0BF0BA2CD9203A00D10289 /* MSAL in Frameworks */ = {isa = PBXBuildFile; productRef = CE0BF0B92CD9203A00D10289 /* MSAL */; }; + CE3BD14E2CBEB0DA0026F4E3 /* PluginManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE3BD14D2CBEB0DA0026F4E3 /* PluginManager.swift */; }; + CE5712792CD1099B00D4AB17 /* OEXFirebaseAnalytics in Frameworks */ = {isa = PBXBuildFile; productRef = CE5712782CD1099B00D4AB17 /* OEXFirebaseAnalytics */; }; + CE57127A2CD109A800D4AB17 /* OEXFoundation in Embed Frameworks */ = {isa = PBXBuildFile; productRef = CE9C07D72CD104E5009C44D1 /* OEXFoundation */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; + CE924BE72CD8FAB3000137CA /* FirebaseMessaging in Frameworks */ = {isa = PBXBuildFile; productRef = CE924BE62CD8FAB3000137CA /* FirebaseMessaging */; }; + CE9C07D82CD104E5009C44D1 /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CE9C07D72CD104E5009C44D1 /* OEXFoundation */; }; 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, ); }; }; /* End PBXBuildFile section */ @@ -84,6 +79,7 @@ 072787B228D34D83002E9142 /* Discovery.framework in Embed Frameworks */, 0218196528F734FA00202564 /* Discussion.framework in Embed Frameworks */, 07A7D79028F5C9060000BE81 /* Core.framework in Embed Frameworks */, + CE57127A2CD109A800D4AB17 /* OEXFoundation in Embed Frameworks */, 0770DE4C28D0A462006D8A5D /* Authorization.framework in Embed Frameworks */, 0219C67828F4347600D64452 /* Course.framework in Embed Frameworks */, 025DE1A528DB4DAE0053E0F4 /* Profile.framework in Embed Frameworks */, @@ -136,7 +132,6 @@ 07D5DA3428D075AA00752FD9 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 07D5DA3D28D075AB00752FD9 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 149FF39D2B9F1AB50034B33F /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; - 14D912D62C2551F60077CCCE /* FullStoryAnalyticsService.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = FullStoryAnalyticsService.swift; sourceTree = ""; }; 1BB1F1D0FABF8788646FBAF2 /* 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 = ""; }; 37C50995093E34142FDE0ED9 /* 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 = ""; }; A500668A2B613ED10024680B /* PushNotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PushNotificationsManager.swift; sourceTree = ""; }; @@ -148,11 +143,10 @@ 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 /* FirebaseAnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FirebaseAnalyticsService.swift; sourceTree = ""; }; - A5C10D8E2B861A70008E864D /* SegmentAnalyticsService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SegmentAnalyticsService.swift; sourceTree = ""; }; A681C3929FC384F83BCB6648 /* 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 = ""; }; AA8BE99557031F3F33F8037C /* 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 = ""; }; + CE3BD14D2CBEB0DA0026F4E3 /* PluginManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PluginManager.swift; sourceTree = ""; }; DAD1882A21DDAF1F67E4C546 /* 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 = ""; }; E0D6E6A22B1626B10089F9C9 /* Theme.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Theme.framework; sourceTree = BUILT_PRODUCTS_DIR; }; FC49621A0942E5EE74BDC895 /* Pods_App_OpenEdX.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_App_OpenEdX.framework; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -165,22 +159,18 @@ buildActionMask = 2147483647; files = ( E0D6E6A32B1626B10089F9C9 /* Theme.framework in Frameworks */, + CE5712792CD1099B00D4AB17 /* OEXFirebaseAnalytics in Frameworks */, 07A7D78F28F5C9060000BE81 /* Core.framework in Frameworks */, 028A37362ADFF404008CA604 /* WhatsNew.framework in Frameworks */, + CE9C07D82CD104E5009C44D1 /* OEXFoundation in Frameworks */, + CE0BF0BA2CD9203A00D10289 /* MSAL in Frameworks */, 025DE1A428DB4DAE0053E0F4 /* Profile.framework in Frameworks */, 0770DE4B28D0A462006D8A5D /* Authorization.framework in Frameworks */, - BA3042792B1F7147009B64B7 /* MSAL in Frameworks */, - A5462D9F2B865713003B96A5 /* Segment in Frameworks */, 072787B128D34D83002E9142 /* Discovery.framework in Frameworks */, + CE924BE72CD8FAB3000137CA /* FirebaseMessaging in Frameworks */, 0218196428F734FA00202564 /* Discussion.framework in Frameworks */, - 0780ABE52BFBA2E40093A4A6 /* FirebaseMessaging in Frameworks */, 0219C67728F4347600D64452 /* Course.framework in Frameworks */, - 0780ABE32BFBA2E40093A4A6 /* FirebaseAnalytics in Frameworks */, 027DB33028D8A063002B6862 /* Dashboard.framework in Frameworks */, - A5BD3E302B83B0F7006A8983 /* SegmentFirebase in Frameworks */, - A51CDBED2B6D2BEE009B6D4E /* SegmentBraze in Frameworks */, - 14D912D32C25483F0077CCCE /* FullStory in Frameworks */, - A51CDBEF2B6D2BEE009B6D4E /* SegmentBrazeUI in Frameworks */, 1924EDE8164C7AB17AD4946B /* Pods_App_OpenEdX.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -269,14 +259,6 @@ path = OpenEdX; sourceTree = ""; }; - 14D912D52C2551CE0077CCCE /* FullStoryAnalyticsService */ = { - isa = PBXGroup; - children = ( - 14D912D62C2551F60077CCCE /* FullStoryAnalyticsService.swift */, - ); - path = FullStoryAnalyticsService; - sourceTree = ""; - }; 4E6FB43543890E90BB88D64D /* Frameworks */ = { isa = PBXGroup; children = ( @@ -327,9 +309,7 @@ A59568932B6162E400ED4F90 /* DeepLinkManager */, A50066872B613E4B0024680B /* PushNotificationsManager */, A50066892B613E990024680B /* AnalyticsManager */, - A59702272B83C84800CA064C /* FirebaseAnalyticsService */, - A5C10D8D2B861A56008E864D /* SegmentAnalyticsService */, - 14D912D52C2551CE0077CCCE /* FullStoryAnalyticsService */, + CE3BD14D2CBEB0DA0026F4E3 /* PluginManager.swift */, ); path = Managers; sourceTree = ""; @@ -381,22 +361,6 @@ path = Link; sourceTree = ""; }; - A59702272B83C84800CA064C /* FirebaseAnalyticsService */ = { - isa = PBXGroup; - children = ( - A59702282B83C87900CA064C /* FirebaseAnalyticsService.swift */, - ); - path = FirebaseAnalyticsService; - sourceTree = ""; - }; - A5C10D8D2B861A56008E864D /* SegmentAnalyticsService */ = { - isa = PBXGroup; - children = ( - A5C10D8E2B861A70008E864D /* SegmentAnalyticsService.swift */, - ); - path = SegmentAnalyticsService; - sourceTree = ""; - }; A5F46FD02B692B140003EEEF /* Services */ = { isa = PBXGroup; children = ( @@ -428,7 +392,6 @@ 0770DE1528D07845006D8A5D /* Embed Frameworks */, DB97C0542B002EF00035C36F /* Process Config */, 02F175442A4E3B320019CD70 /* FirebaseCrashlytics */, - 14D912D42C25493C0077CCCE /* Run FullStory Asset Uploader */, ); buildRules = ( ); @@ -436,14 +399,10 @@ ); name = OpenEdX; packageProductDependencies = ( - BA3042782B1F7147009B64B7 /* MSAL */, - A51CDBEC2B6D2BEE009B6D4E /* SegmentBraze */, - A51CDBEE2B6D2BEE009B6D4E /* SegmentBrazeUI */, - A5BD3E2F2B83B0F7006A8983 /* SegmentFirebase */, - A5462D9E2B865713003B96A5 /* Segment */, - 0780ABE22BFBA2E40093A4A6 /* FirebaseAnalytics */, - 0780ABE42BFBA2E40093A4A6 /* FirebaseMessaging */, - 14D912D22C25483F0077CCCE /* FullStory */, + CE9C07D72CD104E5009C44D1 /* OEXFoundation */, + CE5712782CD1099B00D4AB17 /* OEXFirebaseAnalytics */, + CE924BE62CD8FAB3000137CA /* FirebaseMessaging */, + CE0BF0B92CD9203A00D10289 /* MSAL */, ); productName = OpenEdX; productReference = 07D5DA3128D075AA00752FD9 /* OpenEdX.app */; @@ -475,12 +434,10 @@ ); mainGroup = 07D5DA2828D075AA00752FD9; packageReferences = ( - BA3042772B1F7147009B64B7 /* XCRemoteSwiftPackageReference "microsoft-authentication-library-for-objc" */, - A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */, - A5BD3E2E2B83B0F7006A8983 /* XCRemoteSwiftPackageReference "analytics-swift-firebase" */, - A5462D9D2B865713003B96A5 /* XCRemoteSwiftPackageReference "analytics-swift" */, - 0780ABE12BFBA2E40093A4A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, - 14D912D12C25483F0077CCCE /* XCRemoteSwiftPackageReference "fullstory-swift-package-ios" */, + CE9C07D62CD104E5009C44D1 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */, + CE9C07D92CD10581009C44D1 /* XCRemoteSwiftPackageReference "openedx-app-firebase-analytics-ios" */, + CE924BE52CD8FAB3000137CA /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */, + CE0BF0B82CD9203A00D10289 /* XCRemoteSwiftPackageReference "microsoft-authentication-library-for-objc" */, ); productRefGroup = 07D5DA3228D075AA00752FD9 /* Products */; projectDirPath = ""; @@ -548,24 +505,6 @@ shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/SwiftLint/swiftlint\"\n"; }; - 14D912D42C25493C0077CCCE /* Run FullStory Asset Uploader */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - name = "Run FullStory Asset Uploader"; - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "if [\"$ FULLSTORY_ENABLED\" = \"YES\"]; then\n \"${PODS_ROOT}/FullStory/tools/FullStoryCommandLine\" \"${CONFIGURATION_BUILD_DIR}/${WRAPPER_NAME}\"\nfi\n"; - }; B9442FD26CE9A85A43FC2CFA /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -630,21 +569,19 @@ 0770DE1E28D084E8006D8A5D /* AppAssembly.swift in Sources */, A5462D9C2B864AE0003B96A5 /* BranchService.swift in Sources */, A50066932B614DCD0024680B /* FCMListener.swift in Sources */, - A59702292B83C87900CA064C /* FirebaseAnalyticsService.swift in Sources */, 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 */, - 14D912D72C2551F60077CCCE /* FullStoryAnalyticsService.swift in Sources */, 0293A2092A6FCDE50090A336 /* DashboardPersistence.swift in Sources */, 0770DE1728D080A1006D8A5D /* RouteController.swift in Sources */, + CE3BD14E2CBEB0DA0026F4E3 /* PluginManager.swift in Sources */, A59568952B61630500ED4F90 /* DeepLinkManager.swift in Sources */, A50066952B614DEF0024680B /* BrazeListener.swift in Sources */, 071009C928D1DB3F00344290 /* ScreenAssembly.swift in Sources */, A59568972B61653700ED4F90 /* DeepLink.swift in Sources */, 022213D22C0E08E500B917E6 /* ProfilePersistence.swift in Sources */, - A5C10D8F2B861A70008E864D /* SegmentAnalyticsService.swift in Sources */, A59568992B616D9400ED4F90 /* PushLink.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -1291,96 +1228,60 @@ /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ - 0780ABE12BFBA2E40093A4A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { + CE0BF0B82CD9203A00D10289 /* XCRemoteSwiftPackageReference "microsoft-authentication-library-for-objc" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/firebase/firebase-ios-sdk"; + repositoryURL = "https://github.com/AzureAD/microsoft-authentication-library-for-objc"; requirement = { kind = upToNextMajorVersion; - minimumVersion = 11.3.0; + minimumVersion = 1.6.1; }; }; - 14D912D12C25483F0077CCCE /* XCRemoteSwiftPackageReference "fullstory-swift-package-ios" */ = { + CE924BE52CD8FAB3000137CA /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/fullstorydev/fullstory-swift-package-ios"; + repositoryURL = "https://github.com/firebase/firebase-ios-sdk.git"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.53.0; + kind = exactVersion; + version = 11.3.0; }; }; - A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */ = { + CE9C07D62CD104E5009C44D1 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/braze-inc/braze-segment-swift"; + repositoryURL = "https://github.com/openedx/openedx-app-foundation-ios/"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 4.0.0; + kind = exactVersion; + version = 1.0.0; }; }; - A5462D9D2B865713003B96A5 /* XCRemoteSwiftPackageReference "analytics-swift" */ = { + CE9C07D92CD10581009C44D1 /* XCRemoteSwiftPackageReference "openedx-app-firebase-analytics-ios" */ = { isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/segmentio/analytics-swift.git"; + repositoryURL = "https://github.com/openedx/openedx-app-firebase-analytics-ios"; requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.6.1; - }; - }; - A5BD3E2E2B83B0F7006A8983 /* XCRemoteSwiftPackageReference "analytics-swift-firebase" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/segment-integrations/analytics-swift-firebase"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.4.0; - }; - }; - BA3042772B1F7147009B64B7 /* XCRemoteSwiftPackageReference "microsoft-authentication-library-for-objc" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/AzureAD/microsoft-authentication-library-for-objc"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.5.1; + kind = exactVersion; + version = 1.0.0; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 0780ABE22BFBA2E40093A4A6 /* FirebaseAnalytics */ = { - isa = XCSwiftPackageProductDependency; - package = 0780ABE12BFBA2E40093A4A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseAnalytics; - }; - 0780ABE42BFBA2E40093A4A6 /* FirebaseMessaging */ = { - isa = XCSwiftPackageProductDependency; - package = 0780ABE12BFBA2E40093A4A6 /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; - productName = FirebaseMessaging; - }; - 14D912D22C25483F0077CCCE /* FullStory */ = { + CE0BF0B92CD9203A00D10289 /* MSAL */ = { isa = XCSwiftPackageProductDependency; - package = 14D912D12C25483F0077CCCE /* XCRemoteSwiftPackageReference "fullstory-swift-package-ios" */; - productName = FullStory; - }; - A51CDBEC2B6D2BEE009B6D4E /* SegmentBraze */ = { - isa = XCSwiftPackageProductDependency; - package = A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */; - productName = SegmentBraze; - }; - A51CDBEE2B6D2BEE009B6D4E /* SegmentBrazeUI */ = { - isa = XCSwiftPackageProductDependency; - package = A51CDBEB2B6D2BEE009B6D4E /* XCRemoteSwiftPackageReference "braze-segment-swift" */; - productName = SegmentBrazeUI; + package = CE0BF0B82CD9203A00D10289 /* XCRemoteSwiftPackageReference "microsoft-authentication-library-for-objc" */; + productName = MSAL; }; - A5462D9E2B865713003B96A5 /* Segment */ = { + CE5712782CD1099B00D4AB17 /* OEXFirebaseAnalytics */ = { isa = XCSwiftPackageProductDependency; - package = A5462D9D2B865713003B96A5 /* XCRemoteSwiftPackageReference "analytics-swift" */; - productName = Segment; + package = CE9C07D92CD10581009C44D1 /* XCRemoteSwiftPackageReference "openedx-app-firebase-analytics-ios" */; + productName = OEXFirebaseAnalytics; }; - A5BD3E2F2B83B0F7006A8983 /* SegmentFirebase */ = { + CE924BE62CD8FAB3000137CA /* FirebaseMessaging */ = { isa = XCSwiftPackageProductDependency; - package = A5BD3E2E2B83B0F7006A8983 /* XCRemoteSwiftPackageReference "analytics-swift-firebase" */; - productName = SegmentFirebase; + package = CE924BE52CD8FAB3000137CA /* XCRemoteSwiftPackageReference "firebase-ios-sdk" */; + productName = FirebaseMessaging; }; - BA3042782B1F7147009B64B7 /* MSAL */ = { + CE9C07D72CD104E5009C44D1 /* OEXFoundation */ = { isa = XCSwiftPackageProductDependency; - package = BA3042772B1F7147009B64B7 /* XCRemoteSwiftPackageReference "microsoft-authentication-library-for-objc" */; - productName = MSAL; + package = CE9C07D62CD104E5009C44D1 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */; + productName = OEXFoundation; }; /* End XCSwiftPackageProductDependency section */ }; diff --git a/OpenEdX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/OpenEdX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..62b5e29a9 --- /dev/null +++ b/OpenEdX.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,204 @@ +{ + "originHash" : "7091edbbbbea71591e476364909f2e5f04e1211b9c58ac97e2712e9546afdd90", + "pins" : [ + { + "identity" : "abseil-cpp-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/abseil-cpp-binary.git", + "state" : { + "revision" : "194a6706acbd25e4ef639bcaddea16e8758a3e27", + "version" : "1.2024011602.0" + } + }, + { + "identity" : "analytics-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/analytics-swift.git", + "state" : { + "revision" : "825bac9da99ca02bacf85bdf95f707d8e9f786d1", + "version" : "1.6.2" + } + }, + { + "identity" : "analytics-swift-firebase", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segment-integrations/analytics-swift-firebase", + "state" : { + "revision" : "8d955ec9554869e9f1eaf2c265d439d12947f8b3", + "version" : "1.4.0" + } + }, + { + "identity" : "app-check", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/app-check.git", + "state" : { + "revision" : "87dd288fc792bf9751e522e171a47df5b783b0b8", + "version" : "11.1.0" + } + }, + { + "identity" : "braze-segment-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/braze-inc/braze-segment-swift", + "state" : { + "revision" : "0f1fb36c89bf4f057e89c93ec0f1a159f33d8fd5", + "version" : "4.0.0" + } + }, + { + "identity" : "braze-swift-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/braze-inc/braze-swift-sdk", + "state" : { + "revision" : "f6b0226e04d19bb79f7fa57cf9f1aa56abe465ff", + "version" : "10.3.1" + } + }, + { + "identity" : "firebase-ios-sdk", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/firebase-ios-sdk", + "state" : { + "revision" : "f909f901bfba9e27e4e9da83242a4915d6dd64bb", + "version" : "11.3.0" + } + }, + { + "identity" : "fullstory-swift-package-ios", + "kind" : "remoteSourceControl", + "location" : "https://github.com/fullstorydev/fullstory-swift-package-ios", + "state" : { + "revision" : "5ba8ef3c359f676f4a5e56c7bf8d68fa998a6362", + "version" : "1.53.0" + } + }, + { + "identity" : "googleappmeasurement", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleAppMeasurement.git", + "state" : { + "revision" : "93406fd21b85e66e2d6dbf50b472161fd75c3f1f", + "version" : "11.3.0" + } + }, + { + "identity" : "googledatatransport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleDataTransport.git", + "state" : { + "revision" : "617af071af9aa1d6a091d59a202910ac482128f9", + "version" : "10.1.0" + } + }, + { + "identity" : "googleutilities", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/GoogleUtilities.git", + "state" : { + "revision" : "53156c7ec267db846e6b64c9f4c4e31ba4cf75eb", + "version" : "8.0.2" + } + }, + { + "identity" : "grpc-binary", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/grpc-binary.git", + "state" : { + "revision" : "f56d8fc3162de9a498377c7b6cea43431f4f5083", + "version" : "1.65.1" + } + }, + { + "identity" : "gtm-session-fetcher", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/gtm-session-fetcher.git", + "state" : { + "revision" : "a2ab612cb980066ee56d90d60d8462992c07f24b", + "version" : "3.5.0" + } + }, + { + "identity" : "interop-ios-for-google-sdks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/interop-ios-for-google-sdks.git", + "state" : { + "revision" : "2d12673670417654f08f5f90fdd62926dc3a2648", + "version" : "100.0.0" + } + }, + { + "identity" : "jsonsafeencoding-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/jsonsafeencoding-swift.git", + "state" : { + "revision" : "af6a8b360984085e36c6341b21ecb35c12f47ebd", + "version" : "2.0.0" + } + }, + { + "identity" : "leveldb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/leveldb.git", + "state" : { + "revision" : "a0bc79961d7be727d258d33d5a6b2f1023270ba1", + "version" : "1.22.5" + } + }, + { + "identity" : "microsoft-authentication-library-for-objc", + "kind" : "remoteSourceControl", + "location" : "https://github.com/AzureAD/microsoft-authentication-library-for-objc", + "state" : { + "revision" : "a20fd4c4587405da35723940d6ac0ee06a7b2b17", + "version" : "1.6.0" + } + }, + { + "identity" : "nanopb", + "kind" : "remoteSourceControl", + "location" : "https://github.com/firebase/nanopb.git", + "state" : { + "revision" : "b7e1104502eca3a213b46303391ca4d3bc8ddec1", + "version" : "2.30910.0" + } + }, + { + "identity" : "promises", + "kind" : "remoteSourceControl", + "location" : "https://github.com/google/promises.git", + "state" : { + "revision" : "540318ecedd63d883069ae7f1ed811a2df00b6ac", + "version" : "2.4.0" + } + }, + { + "identity" : "sdwebimage", + "kind" : "remoteSourceControl", + "location" : "https://github.com/SDWebImage/SDWebImage.git", + "state" : { + "revision" : "8a1be70a625683bc04d6903e2935bf23f3c6d609", + "version" : "5.19.7" + } + }, + { + "identity" : "sovran-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/segmentio/sovran-swift.git", + "state" : { + "revision" : "24867f3e4ac62027db9827112135e6531b6f4051", + "version" : "1.1.2" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "ebc7251dd5b37f627c93698e4374084d98409633", + "version" : "1.28.2" + } + } + ], + "version" : 3 +} diff --git a/OpenEdX/AppDelegate.swift b/OpenEdX/AppDelegate.swift index f21e60633..d828970d6 100644 --- a/OpenEdX/AppDelegate.swift +++ b/OpenEdX/AppDelegate.swift @@ -7,12 +7,14 @@ import UIKit import Core +import OEXFoundation import Swinject import Profile import GoogleSignIn import FacebookCore import MSAL import UserNotifications +import OEXFirebaseAnalytics import FirebaseCore import FirebaseMessaging import Theme @@ -29,6 +31,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { var window: UIWindow? + private let pluginManager = PluginManager() private var assembler: Assembler? private var lastForceLogoutTime: TimeInterval = 0 @@ -38,6 +41,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { initDI() + initPlugins() if let config = Container.shared.resolve(ConfigProtocol.self) { Theme.Shapes.isRoundedCorners = config.theme.isRoundedCorners @@ -129,6 +133,15 @@ class AppDelegate: UIResponder, UIApplicationDelegate { return false } + + private func initPlugins() { + guard let config = Container.shared.resolve(ConfigProtocol.self) else { return } + if config.firebase.enabled && config.firebase.isAnalyticsSourceFirebase { + pluginManager.addPlugin(analyticsService: FirebaseAnalyticsService()) + } + + // Initialize your plugins here + } private func initDI() { let navigation = UINavigationController() @@ -136,7 +149,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate { assembler = Assembler( [ - AppAssembly(navigation: navigation), + AppAssembly(navigation: navigation, pluginManager: pluginManager), NetworkAssembly(), ScreenAssembly() ], diff --git a/OpenEdX/DI/AppAssembly.swift b/OpenEdX/DI/AppAssembly.swift index 0ab06b29a..ffa6fb450 100644 --- a/OpenEdX/DI/AppAssembly.swift +++ b/OpenEdX/DI/AppAssembly.swift @@ -7,6 +7,8 @@ import UIKit import Core +import OEXFoundation +import OEXFirebaseAnalytics import Swinject import KeychainSwift import Discovery @@ -21,9 +23,11 @@ import WhatsNew class AppAssembly: Assembly { private let navigation: UINavigationController + private let pluginManager: PluginManager - init(navigation: UINavigationController) { + init(navigation: UINavigationController, pluginManager: PluginManager) { self.navigation = navigation + self.pluginManager = pluginManager } func assemble(container: Container) { @@ -31,14 +35,16 @@ class AppAssembly: Assembly { self.navigation }.inObjectScope(.container) + container.register(PluginManager.self) { _ in + self.pluginManager + }.inObjectScope(.container) + container.register(Router.self) { r in Router(navigationController: r.resolve(UINavigationController.self)!, container: container) } container.register(AnalyticsManager.self) { r in - AnalyticsManager( - config: r.resolve(ConfigProtocol.self)! - ) + AnalyticsManager(services: r.resolve(PluginManager.self)!.analyticsServices) } container.register(AuthorizationAnalytics.self) { r in @@ -208,20 +214,10 @@ class AppAssembly: Assembly { ) }.inObjectScope(.container) - container.register(SegmentAnalyticsService.self) { r in - SegmentAnalyticsService( - config: r.resolve(ConfigProtocol.self)! - ) - }.inObjectScope(.container) - container.register(FirebaseAnalyticsService.self) { _ in FirebaseAnalyticsService() }.inObjectScope(.container) - container.register(FullStoryAnalyticsService.self) { r in - FullStoryAnalyticsService() - }.inObjectScope(.container) - container.register(PipManagerProtocol.self) { r in let config = r.resolve(ConfigProtocol.self)! return PipManager( diff --git a/OpenEdX/DI/NetworkAssembly.swift b/OpenEdX/DI/NetworkAssembly.swift index 83537fb29..904c1ea44 100644 --- a/OpenEdX/DI/NetworkAssembly.swift +++ b/OpenEdX/DI/NetworkAssembly.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation import Alamofire import Swinject @@ -38,7 +39,7 @@ class NetworkAssembly: Assembly { }.inObjectScope(.container) container.register(API.self) {r in - API(session: r.resolve(Alamofire.Session.self)!, config: r.resolve(ConfigProtocol.self)!) + API(session: r.resolve(Alamofire.Session.self)!, baseURL: r.resolve(ConfigProtocol.self)!.baseURL) }.inObjectScope(.container) } } diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 4ef5e7321..551483b91 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -8,6 +8,7 @@ import Foundation import Swinject import Core +import OEXFoundation import Authorization import Discovery import Dashboard diff --git a/OpenEdX/Data/CorePersistence.swift b/OpenEdX/Data/CorePersistence.swift index 4844aafdc..80ffcf83c 100644 --- a/OpenEdX/Data/CorePersistence.swift +++ b/OpenEdX/Data/CorePersistence.swift @@ -6,6 +6,7 @@ // import Core +import OEXFoundation import Foundation import CoreData import Combine diff --git a/OpenEdX/Data/Network/NotificationsEndpoints.swift b/OpenEdX/Data/Network/NotificationsEndpoints.swift index 8b5639f6c..4af73fb69 100644 --- a/OpenEdX/Data/Network/NotificationsEndpoints.swift +++ b/OpenEdX/Data/Network/NotificationsEndpoints.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation import Alamofire enum NotificationsEndpoints: EndPointType { diff --git a/OpenEdX/Data/ProfilePersistence.swift b/OpenEdX/Data/ProfilePersistence.swift index 9b37befc9..afc4f28ec 100644 --- a/OpenEdX/Data/ProfilePersistence.swift +++ b/OpenEdX/Data/ProfilePersistence.swift @@ -7,6 +7,7 @@ import Profile import Core +import OEXFoundation import Foundation import CoreData diff --git a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift index 6949a193a..f7bfa05b8 100644 --- a/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift +++ b/OpenEdX/Managers/AnalyticsManager/AnalyticsManager.swift @@ -15,12 +15,8 @@ import Course import Discussion import WhatsNew import Swinject - -protocol AnalyticsService { - func identify(id: String, username: String?, email: String?) - func logEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) - func logScreenEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) -} +import OEXFoundation +import OEXFirebaseAnalytics // swiftlint:disable type_body_length file_length class AnalyticsManager: AuthorizationAnalytics, @@ -33,33 +29,11 @@ class AnalyticsManager: AuthorizationAnalytics, CoreAnalytics, WhatsNewAnalytics { - private var services: [AnalyticsService] = [] + 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 Firebase Analytics Service - if config.firebase.enabled && config.firebase.isAnalyticsSourceFirebase, - let firebaseService = Container.shared.resolve(FirebaseAnalyticsService.self) { - analyticsServices.append(firebaseService) - } - - // add Segment Analytics Service - if config.segment.enabled, - let segmentService = Container.shared.resolve(SegmentAnalyticsService.self) { - analyticsServices.append(segmentService) - } - - if config.fullStory.enabled, - let fullStoryService = Container.shared.resolve(FullStoryAnalyticsService.self) { - analyticsServices.append(fullStoryService) - } - - return analyticsServices + public init(services: [AnalyticsService]) { + self.services = services } public func identify(id: String, username: String, email: String) { @@ -70,13 +44,13 @@ class AnalyticsManager: AuthorizationAnalytics, private func logEvent(_ event: AnalyticsEvent, parameters: [String: Any]? = nil) { for service in services { - service.logEvent(event, parameters: parameters) + service.logEvent(event.rawValue, parameters: parameters) } } private func logScreenEvent(_ event: AnalyticsEvent, parameters: [String: Any]? = nil) { for service in services { - service.logScreenEvent(event, parameters: parameters) + service.logScreenEvent(event.rawValue, parameters: parameters) } } diff --git a/OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift b/OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift index 90ca14792..a1c1a7ae7 100644 --- a/OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift +++ b/OpenEdX/Managers/DeepLinkManager/Link/DeepLink.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation enum DeepLinkType: String { case courseDashboard = "course_dashboard" diff --git a/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift b/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift deleted file mode 100644 index 396af313f..000000000 --- a/OpenEdX/Managers/FirebaseAnalyticsService/FirebaseAnalyticsService.swift +++ /dev/null @@ -1,94 +0,0 @@ -// -// FirebaseAnalyticsService.swift -// OpenEdX -// -// Created by Anton Yarmolenka on 19/02/2024. -// - -import Foundation -import Core -import FirebaseAnalytics - -private let MaxParameterValueCharacters = 100 -private let MaxNameValueCharacters = 40 - -class FirebaseAnalyticsService: AnalyticsService { - - func identify(id: String, username: String?, email: String?) { - Analytics.setUserID(id) - } - - 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)) - } - - func logScreenEvent(_ event: Core.AnalyticsEvent, parameters: [String: Any]?) { - logEvent(event, parameters: 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/FullStoryAnalyticsService/FullStoryAnalyticsService.swift b/OpenEdX/Managers/FullStoryAnalyticsService/FullStoryAnalyticsService.swift deleted file mode 100644 index 69b594cd7..000000000 --- a/OpenEdX/Managers/FullStoryAnalyticsService/FullStoryAnalyticsService.swift +++ /dev/null @@ -1,25 +0,0 @@ -// -// FullStoryAnalyticsService.swift -// OpenEdX -// -// Created by Saeed Bashir on 4/17/24. -// - -import Foundation -import Core -import FullStory - -class FullStoryAnalyticsService: AnalyticsService { - - func identify(id: String, username: String?, email: String?) { - FS.identify(id, userVars: ["displayName": id]) - } - - func logEvent(_ event: AnalyticsEvent, parameters: [String: Any]?) { - FS.event(event.rawValue, properties: parameters ?? [:]) - } - - func logScreenEvent(_ event: Core.AnalyticsEvent, parameters: [String: Any]?) { - FS.page(withName: event.rawValue, properties: parameters).start() - } -} diff --git a/OpenEdX/Managers/PluginManager.swift b/OpenEdX/Managers/PluginManager.swift new file mode 100644 index 000000000..f0661aa6d --- /dev/null +++ b/OpenEdX/Managers/PluginManager.swift @@ -0,0 +1,20 @@ +// +// PluginManager.swift +// OpenEdX +// +// Created by Ivan Stepanok on 15.10.2024. +// + +import Foundation +import OEXFoundation + +public class PluginManager { + + private(set) var analyticsServices: [AnalyticsService] = [] + + public init() {} + + func addPlugin(analyticsService: AnalyticsService) { + analyticsServices.append(analyticsService) + } +} diff --git a/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift b/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift index 0a78688e4..1e4d37220 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Listeners/BrazeListener.swift @@ -26,9 +26,13 @@ class BrazeListener: PushNotificationsListener { guard let dictionary = userInfo as? [String: AnyHashable], shouldListenNotification(userinfo: userInfo) else { return } - if let segmentService = Container.shared.resolve(SegmentAnalyticsService.self) { - segmentService.analytics?.receivedRemoteNotification(userInfo: userInfo) - } + // Removed as part of the move to a plugin architecture, this code should be called from the plugin. + +// if let segmentService = Container.shared.resolve(SegmentAnalyticsService.self) { +// segmentService.analytics?.receivedRemoteNotification(userInfo: userInfo) +// } + + let link = PushLink(dictionary: dictionary) deepLinkManager.processLinkFromNotification(link) diff --git a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift index 3b93fec87..3ef708cb1 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Providers/BrazeProvider.swift @@ -6,24 +6,26 @@ // import Foundation -import SegmentBrazeUI import Swinject +import OEXFoundation class BrazeProvider: PushNotificationsProvider { func didRegisterWithDeviceToken(deviceToken: Data) { - guard let segmentService = Container.shared.resolve(SegmentAnalyticsService.self) else { return } - segmentService.analytics?.add( - plugin: BrazeDestination( - additionalConfiguration: { configuration in - configuration.logger.level = .info - }, additionalSetup: { braze in - braze.notifications.register(deviceToken: deviceToken) - } - ) - ) + // Removed as part of the move to a plugin architecture, this code should be called from the plugin. - segmentService.analytics?.registeredForRemoteNotifications(deviceToken: deviceToken) +// guard let segmentService = Container.shared.resolve(SegmentAnalyticsService.self) else { return } +// segmentService.analytics?.add( +// plugin: BrazeDestination( +// additionalConfiguration: { configuration in +// configuration.logger.level = .info +// }, additionalSetup: { braze in +// braze.notifications.register(deviceToken: deviceToken) +// } +// ) +// ) +// +// segmentService.analytics?.registeredForRemoteNotifications(deviceToken: deviceToken) } func didFailToRegisterForRemoteNotificationsWithError(error: Error) { diff --git a/OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift b/OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift index 6a418ce5f..71b0c82c3 100644 --- a/OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift +++ b/OpenEdX/Managers/PushNotificationsManager/Providers/FCMProvider.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation import FirebaseCore import FirebaseMessaging diff --git a/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift b/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift index bd218ed02..2f9c3c1b4 100644 --- a/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift +++ b/OpenEdX/Managers/PushNotificationsManager/PushNotificationsManager.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation import UIKit import UserNotifications import FirebaseCore diff --git a/OpenEdX/Managers/SegmentAnalyticsService/SegmentAnalyticsService.swift b/OpenEdX/Managers/SegmentAnalyticsService/SegmentAnalyticsService.swift deleted file mode 100644 index fad86a131..000000000 --- a/OpenEdX/Managers/SegmentAnalyticsService/SegmentAnalyticsService.swift +++ /dev/null @@ -1,48 +0,0 @@ -// -// SegmentAnalyticsService.swift -// OpenEdX -// -// Created by Anton Yarmolenka on 21/02/2024. -// - -import Foundation -import Core -import Segment -import SegmentFirebase - -class SegmentAnalyticsService: AnalyticsService { - var analytics: Analytics? - - // Init manager - public init(config: ConfigProtocol) { - guard config.segment.enabled else { return } - - let configuration = Configuration(writeKey: config.segment.writeKey) - .trackApplicationLifecycleEvents(true) - .flushInterval(10) - analytics = Analytics(configuration: configuration) - if config.firebase.enabled && config.firebase.isAnalyticsSourceSegment { - 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: AnalyticsEvent, parameters: [String: Any]?) { - analytics?.track( - name: event.rawValue, - properties: parameters - ) - } - - func logScreenEvent(_ event: Core.AnalyticsEvent, parameters: [String: Any]?) { - analytics?.screen(title: event.rawValue, properties: parameters) - } -} diff --git a/OpenEdX/View/MainScreenViewModel.swift b/OpenEdX/View/MainScreenViewModel.swift index da496110c..7c2d17f17 100644 --- a/OpenEdX/View/MainScreenViewModel.swift +++ b/OpenEdX/View/MainScreenViewModel.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation import Profile import Course import Swinject diff --git a/Podfile b/Podfile index 929bdb7b9..a207a2f84 100644 --- a/Podfile +++ b/Podfile @@ -16,15 +16,8 @@ abstract_target "App" do target "Core" do project './Core/Core.xcodeproj' workspace './Core/Core.xcodeproj' - #Networking - pod 'Alamofire', :git => 'https://github.com/Alamofire/Alamofire.git', :tag => '5.10.0' -#Keychain + #Keychain pod 'KeychainSwift', '~> 24.0' - #SwiftUI backward UIKit access - #pod 'Introspect', '~> 0.6' - pod 'SwiftUIIntrospect', '~> 1.3' - pod 'Kingfisher', '~> 8.0' - pod 'Swinject', '2.9.1' target 'CoreTests' do pod 'SwiftyMocky', :git => 'https://github.com/MakeAWishFoundation/SwiftyMocky.git', :tag => '4.2.0' diff --git a/Podfile.lock b/Podfile.lock index edbba665e..32a960dfc 100644 --- a/Podfile.lock +++ b/Podfile.lock @@ -1,64 +1,43 @@ PODS: - - Alamofire (5.10.0) - KeychainSwift (24.0.0) - - Kingfisher (8.0.3) - Sourcery (1.8.0): - Sourcery/CLI-Only (= 1.8.0) - Sourcery/CLI-Only (1.8.0) - SwiftGen (6.6.3) - SwiftLint (0.57.0) - - SwiftUIIntrospect (1.3.0) - SwiftyMocky (4.2.0): - Sourcery (= 1.8.0) - - Swinject (2.9.1) DEPENDENCIES: - - Alamofire (from `https://github.com/Alamofire/Alamofire.git`, tag `5.10.0`) - KeychainSwift (~> 24.0) - - Kingfisher (~> 8.0) - SwiftGen (~> 6.6) - SwiftLint (~> 0.57.0) - - SwiftUIIntrospect (~> 1.3) - SwiftyMocky (from `https://github.com/MakeAWishFoundation/SwiftyMocky.git`, tag `4.2.0`) - - Swinject (= 2.9.1) SPEC REPOS: trunk: - KeychainSwift - - Kingfisher - Sourcery - SwiftGen - SwiftLint - - SwiftUIIntrospect - - Swinject EXTERNAL SOURCES: - Alamofire: - :git: https://github.com/Alamofire/Alamofire.git - :tag: 5.10.0 SwiftyMocky: :git: https://github.com/MakeAWishFoundation/SwiftyMocky.git :tag: 4.2.0 CHECKOUT OPTIONS: - Alamofire: - :git: https://github.com/Alamofire/Alamofire.git - :tag: 5.10.0 SwiftyMocky: :git: https://github.com/MakeAWishFoundation/SwiftyMocky.git :tag: 4.2.0 SPEC CHECKSUMS: - Alamofire: cd0b98508df05796dd2ff278f3bb055a631b5390 KeychainSwift: 007c4647486e4563adca839cf02cef00deb3b670 - Kingfisher: bbf78af014cc845cf9a799363f627b5212784165 Sourcery: 6f5fe49b82b7e02e8c65560cbd52e1be67a1af2e SwiftGen: 4993cbf71cbc4886f775e26f8d5c3a1188ec9f99 SwiftLint: eb47480d47c982481592c195c221d11013a679cc - SwiftUIIntrospect: fee9aa07293ee280373a591e1824e8ddc869ba5d SwiftyMocky: c5e96e4ff76ec6dbf5a5941aeb039b5a546954a0 - Swinject: a827d508c6270da03ec74e558e728917a888fa9b -PODFILE CHECKSUM: a4fdd0279f24855bc71cef3096c188e41977d96c +PODFILE CHECKSUM: fe79196bcbd67eb66f3dd20e3a90c1210980722d COCOAPODS: 1.15.2 diff --git a/Profile/Mockfile b/Profile/Mockfile index 408c90399..9d3f7e354 100644 --- a/Profile/Mockfile +++ b/Profile/Mockfile @@ -14,4 +14,5 @@ unit.tests.mock: - Profile - Foundation - SwiftUI - - Combine \ No newline at end of file + - Combine + - OEXFoundation \ No newline at end of file diff --git a/Profile/Profile.xcodeproj/project.pbxproj b/Profile/Profile.xcodeproj/project.pbxproj index cdced1def..2fc6aba89 100644 --- a/Profile/Profile.xcodeproj/project.pbxproj +++ b/Profile/Profile.xcodeproj/project.pbxproj @@ -60,6 +60,9 @@ BAD9CA3F2B29BF5C00DE790A /* ProfileSupportInfoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAD9CA3E2B29BF5C00DE790A /* ProfileSupportInfoView.swift */; }; CE1735042CD23D7A00F9606A /* DatesAndCalendarViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE1735032CD23D7A00F9606A /* DatesAndCalendarViewModelTests.swift */; }; CE961F032CD163FD00799B9F /* CalendarManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE961F022CD163FD00799B9F /* CalendarManagerTests.swift */; }; + CE7CAF3D2CC1562C00E0AC9D /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CE7CAF3C2CC1562C00E0AC9D /* OEXFoundation */; }; + CEB1E2702CC14EB000921517 /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CEB1E26F2CC14EB000921517 /* OEXFoundation */; }; + CEBCA4312CC13CB900076589 /* BranchSDK in Frameworks */ = {isa = PBXBuildFile; productRef = CEBCA4302CC13CB900076589 /* BranchSDK */; }; E8264C634DD8AD314ECE8905 /* Pods_App_Profile_ProfileTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C85ADF87135E03275A980E07 /* Pods_App_Profile_ProfileTests.framework */; }; /* End PBXBuildFile section */ @@ -73,6 +76,19 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + CE7CAF3F2CC1562C00E0AC9D /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 020102D029784B3100BBF80C /* EditProfileViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditProfileViewModelTests.swift; sourceTree = ""; }; 020306C72932B13F000949EA /* EditProfileView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditProfileView.swift; sourceTree = ""; }; @@ -152,6 +168,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + CEB1E2702CC14EB000921517 /* OEXFoundation in Frameworks */, + CEBCA4312CC13CB900076589 /* BranchSDK in Frameworks */, 025DE1A028DB4D9D0053E0F4 /* Core.framework in Frameworks */, 25B36FF48C1307888A3890DA /* Pods_App_Profile.framework in Frameworks */, ); @@ -162,6 +180,7 @@ buildActionMask = 2147483647; files = ( 02A9A91E2978194A00B55797 /* Profile.framework in Frameworks */, + CE7CAF3D2CC1562C00E0AC9D /* OEXFoundation in Frameworks */, E8264C634DD8AD314ECE8905 /* Pods_App_Profile_ProfileTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -497,6 +516,7 @@ 02A9A9172978194A00B55797 /* Frameworks */, 02A9A9182978194A00B55797 /* Resources */, E4D48C711DA7F62E34A40309 /* [CP] Copy Pods Resources */, + CE7CAF3F2CC1562C00E0AC9D /* Embed Frameworks */, ); buildRules = ( ); @@ -547,6 +567,10 @@ uk, ); mainGroup = 020F834028DB4CCD0062FA70; + packageReferences = ( + CEBCA42F2CC13CB900076589 /* XCRemoteSwiftPackageReference "ios-branch-sdk-spm" */, + CEB1E26E2CC14EB000921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */, + ); productRefGroup = 020F834B28DB4CCD0062FA70 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -1718,6 +1742,43 @@ }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + CEB1E26E2CC14EB000921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/openedx/openedx-app-foundation-ios/"; + requirement = { + kind = exactVersion; + version = 1.0.0; + }; + }; + CEBCA42F2CC13CB900076589 /* XCRemoteSwiftPackageReference "ios-branch-sdk-spm" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/BranchMetrics/ios-branch-sdk-spm"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 3.6.4; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + CE7CAF3C2CC1562C00E0AC9D /* OEXFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = CEB1E26E2CC14EB000921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */; + productName = OEXFoundation; + }; + CEB1E26F2CC14EB000921517 /* OEXFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = CEB1E26E2CC14EB000921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */; + productName = OEXFoundation; + }; + CEBCA4302CC13CB900076589 /* BranchSDK */ = { + isa = XCSwiftPackageProductDependency; + package = CEBCA42F2CC13CB900076589 /* XCRemoteSwiftPackageReference "ios-branch-sdk-spm" */; + productName = BranchSDK; + }; +/* End XCSwiftPackageProductDependency section */ + /* Begin XCVersionGroup section */ 022213D32C0E092300B917E6 /* ProfileCoreModel.xcdatamodeld */ = { isa = XCVersionGroup; diff --git a/Profile/Profile/Data/Network/ProfileEndpoint.swift b/Profile/Profile/Data/Network/ProfileEndpoint.swift index a72264ebd..c169ec575 100644 --- a/Profile/Profile/Data/Network/ProfileEndpoint.swift +++ b/Profile/Profile/Data/Network/ProfileEndpoint.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation import Alamofire enum ProfileEndpoint: EndPointType { diff --git a/Profile/Profile/Data/ProfileRepository.swift b/Profile/Profile/Data/ProfileRepository.swift index e8a20b9c2..8bad93b00 100644 --- a/Profile/Profile/Data/ProfileRepository.swift +++ b/Profile/Profile/Data/ProfileRepository.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation import Alamofire public protocol ProfileRepositoryProtocol { diff --git a/Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift b/Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift index a66ef85fc..da8920ed1 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/CalendarManager.swift @@ -12,6 +12,7 @@ import Theme import BranchSDK import CryptoKit import Core +import OEXFoundation // MARK: - CalendarManager public class CalendarManager: CalendarManagerProtocol { diff --git a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift index 3201e2a1f..ff6ad8c60 100644 --- a/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift +++ b/Profile/Profile/Presentation/DatesAndCalendar/DatesAndCalendarViewModel.swift @@ -12,6 +12,7 @@ import Theme import BranchSDK import CryptoKit import Core +import OEXFoundation // MARK: - DatesAndCalendarViewModel diff --git a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift index 4e7b1e272..2a970f2b6 100644 --- a/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift +++ b/Profile/Profile/Presentation/DeleteAccount/DeleteAccountView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import OEXFoundation import Theme public struct DeleteAccountView: View { diff --git a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift index 7f6c415de..f92e991c3 100644 --- a/Profile/Profile/Presentation/EditProfile/EditProfileView.swift +++ b/Profile/Profile/Presentation/EditProfile/EditProfileView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import OEXFoundation import Theme public struct EditProfileView: View { diff --git a/Profile/Profile/Presentation/Profile/ProfileView.swift b/Profile/Profile/Presentation/Profile/ProfileView.swift index 390a4eec9..54f0026c7 100644 --- a/Profile/Profile/Presentation/Profile/ProfileView.swift +++ b/Profile/Profile/Presentation/Profile/ProfileView.swift @@ -9,6 +9,7 @@ import SwiftUI import Core import Kingfisher import Theme +import OEXFoundation public struct ProfileView: View { diff --git a/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift index cc5d2fc0f..da7e80fd6 100644 --- a/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift +++ b/Profile/Profile/Presentation/Profile/UserProfile/UserProfileView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import OEXFoundation import Kingfisher import Theme diff --git a/Profile/Profile/Presentation/ProfileAnalytics.swift b/Profile/Profile/Presentation/ProfileAnalytics.swift index 6af217a04..2f59ddf3a 100644 --- a/Profile/Profile/Presentation/ProfileAnalytics.swift +++ b/Profile/Profile/Presentation/ProfileAnalytics.swift @@ -7,6 +7,7 @@ import Foundation import Core +import OEXFoundation //sourcery: AutoMockable public protocol ProfileAnalytics { diff --git a/Profile/Profile/Presentation/Settings/ManageAccountView.swift b/Profile/Profile/Presentation/Settings/ManageAccountView.swift index f6c169da2..8b791d42b 100644 --- a/Profile/Profile/Presentation/Settings/ManageAccountView.swift +++ b/Profile/Profile/Presentation/Settings/ManageAccountView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import OEXFoundation import Theme public struct ManageAccountView: View { diff --git a/Profile/Profile/Presentation/Settings/SettingsView.swift b/Profile/Profile/Presentation/Settings/SettingsView.swift index 2d1ee52d9..e257cb967 100644 --- a/Profile/Profile/Presentation/Settings/SettingsView.swift +++ b/Profile/Profile/Presentation/Settings/SettingsView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import OEXFoundation import Kingfisher import Theme diff --git a/Profile/Profile/Presentation/Settings/VideoQualityView.swift b/Profile/Profile/Presentation/Settings/VideoQualityView.swift index 829029111..4f95e139c 100644 --- a/Profile/Profile/Presentation/Settings/VideoQualityView.swift +++ b/Profile/Profile/Presentation/Settings/VideoQualityView.swift @@ -7,6 +7,7 @@ import SwiftUI import Core +import OEXFoundation import Kingfisher import Theme @@ -132,8 +133,8 @@ struct VideoQualityView_Previews: PreviewProvider { router: router, analytics: ProfileAnalyticsMock(), coreAnalytics: CoreAnalyticsMock(), - config: ConfigMock(), - corePersistence: CorePersistenceMock(), + config: ConfigMock(), + corePersistence: CorePersistenceMock(), connectivity: Connectivity() ) diff --git a/Profile/ProfileTests/Presentation/DeleteAccount/DeleteAccountViewModelTests.swift b/Profile/ProfileTests/Presentation/DeleteAccount/DeleteAccountViewModelTests.swift index c6ba81755..2d5b2ed61 100644 --- a/Profile/ProfileTests/Presentation/DeleteAccount/DeleteAccountViewModelTests.swift +++ b/Profile/ProfileTests/Presentation/DeleteAccount/DeleteAccountViewModelTests.swift @@ -9,6 +9,7 @@ import SwiftyMocky import XCTest @testable import Core @testable import Profile +import OEXFoundation import Alamofire import SwiftUI diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index 6a2424da6..38e43e5da 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -13,6 +13,7 @@ import Profile import Foundation import SwiftUI import Combine +import OEXFoundation // MARK: - AuthInteractorProtocol @@ -1518,11 +1519,6 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { } private var __p_branch: (BranchConfig)? - public var segment: SegmentConfig { - get { invocations.append(.p_segment_get); return __p_segment ?? givenGetterValue(.p_segment_get, "ConfigProtocolMock - stub value for segment was not defined") } - } - private var __p_segment: (SegmentConfig)? - public var program: DiscoveryConfig { get { invocations.append(.p_program_get); return __p_program ?? givenGetterValue(.p_program_get, "ConfigProtocolMock - stub value for program was not defined") } } @@ -1533,11 +1529,6 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { } private var __p_URIScheme: (String)? - public var fullStory: FullStoryConfig { - get { invocations.append(.p_fullStory_get); return __p_fullStory ?? givenGetterValue(.p_fullStory_get, "ConfigProtocolMock - stub value for fullStory was not defined") } - } - private var __p_fullStory: (FullStoryConfig)? - @@ -1567,10 +1558,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case p_dashboard_get case p_braze_get case p_branch_get - case p_segment_get case p_program_get case p_URIScheme_get - case p_fullStory_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { switch (lhs, rhs) { case (.p_baseURL_get,.p_baseURL_get): return Matcher.ComparisonResult.match @@ -1596,10 +1585,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case (.p_dashboard_get,.p_dashboard_get): return Matcher.ComparisonResult.match case (.p_braze_get,.p_braze_get): return Matcher.ComparisonResult.match case (.p_branch_get,.p_branch_get): return Matcher.ComparisonResult.match - case (.p_segment_get,.p_segment_get): return Matcher.ComparisonResult.match case (.p_program_get,.p_program_get): return Matcher.ComparisonResult.match case (.p_URIScheme_get,.p_URIScheme_get): return Matcher.ComparisonResult.match - case (.p_fullStory_get,.p_fullStory_get): return Matcher.ComparisonResult.match default: return .none } } @@ -1629,10 +1616,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case .p_dashboard_get: return 0 case .p_braze_get: return 0 case .p_branch_get: return 0 - case .p_segment_get: return 0 case .p_program_get: return 0 case .p_URIScheme_get: return 0 - case .p_fullStory_get: return 0 } } func assertionName() -> String { @@ -1660,10 +1645,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { case .p_dashboard_get: return "[get] .dashboard" case .p_braze_get: return "[get] .braze" case .p_branch_get: return "[get] .branch" - case .p_segment_get: return "[get] .segment" case .p_program_get: return "[get] .program" case .p_URIScheme_get: return "[get] .URIScheme" - case .p_fullStory_get: return "[get] .fullStory" } } } @@ -1745,18 +1728,12 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { public static func branch(getter defaultValue: BranchConfig...) -> PropertyStub { return Given(method: .p_branch_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) } - public static func segment(getter defaultValue: SegmentConfig...) -> PropertyStub { - return Given(method: .p_segment_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } public static func program(getter defaultValue: DiscoveryConfig...) -> PropertyStub { return Given(method: .p_program_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) } public static func URIScheme(getter defaultValue: String...) -> PropertyStub { return Given(method: .p_URIScheme_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) } - public static func fullStory(getter defaultValue: FullStoryConfig...) -> PropertyStub { - return Given(method: .p_fullStory_get, products: defaultValue.map({ StubProduct.return($0 as Any) })) - } } @@ -1786,10 +1763,8 @@ open class ConfigProtocolMock: ConfigProtocol, Mock { public static var dashboard: Verify { return Verify(method: .p_dashboard_get) } public static var braze: Verify { return Verify(method: .p_braze_get) } public static var branch: Verify { return Verify(method: .p_branch_get) } - public static var segment: Verify { return Verify(method: .p_segment_get) } public static var program: Verify { return Verify(method: .p_program_get) } public static var URIScheme: Verify { return Verify(method: .p_URIScheme_get) } - public static var fullStory: Verify { return Verify(method: .p_fullStory_get) } } public struct Perform { diff --git a/WhatsNew/Mockfile b/WhatsNew/Mockfile index 3fee3de2b..6107db3ed 100644 --- a/WhatsNew/Mockfile +++ b/WhatsNew/Mockfile @@ -14,4 +14,5 @@ unit.tests.mock: - WhatsNew - Foundation - SwiftUI - - Combine \ No newline at end of file + - Combine + - OEXFoundation \ No newline at end of file diff --git a/WhatsNew/WhatsNew.xcodeproj/project.pbxproj b/WhatsNew/WhatsNew.xcodeproj/project.pbxproj index 49ad542c3..86fca97de 100644 --- a/WhatsNew/WhatsNew.xcodeproj/project.pbxproj +++ b/WhatsNew/WhatsNew.xcodeproj/project.pbxproj @@ -26,6 +26,8 @@ 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 */; }; + CE7CAF352CC1560900E0AC9D /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CE7CAF342CC1560900E0AC9D /* OEXFoundation */; }; + CEB1E2672CC14E6400921517 /* OEXFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = CEB1E2662CC14E6400921517 /* OEXFoundation */; }; EF5CA11A55CB49F2DA030D25 /* Pods_App_WhatsNew_WhatsNewTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F8D1A5DF016EC4630637336C /* Pods_App_WhatsNew_WhatsNewTests.framework */; }; /* End PBXBuildFile section */ @@ -39,6 +41,19 @@ }; /* End PBXContainerItemProxy section */ +/* Begin PBXCopyFilesBuildPhase section */ + CE7CAF372CC1560900E0AC9D /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXCopyFilesBuildPhase section */ + /* Begin PBXFileReference section */ 020A7B5E2AE131A9000BAF70 /* WhatsNewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WhatsNewModel.swift; sourceTree = ""; }; 020A7B602AE136D2000BAF70 /* WhatsNew.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; name = WhatsNew.json; path = WhatsNew/Data/WhatsNew.json; sourceTree = SOURCE_ROOT; }; @@ -86,6 +101,7 @@ buildActionMask = 2147483647; files = ( 028A373A2ADFF425008CA604 /* Core.framework in Frameworks */, + CEB1E2672CC14E6400921517 /* OEXFoundation in Frameworks */, B3BB9B06B226989A619C6440 /* Pods_App_WhatsNew.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -95,6 +111,7 @@ buildActionMask = 2147483647; files = ( 028A37262ADFF3F8008CA604 /* WhatsNew.framework in Frameworks */, + CE7CAF352CC1560900E0AC9D /* OEXFoundation in Frameworks */, EF5CA11A55CB49F2DA030D25 /* Pods_App_WhatsNew_WhatsNewTests.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -277,6 +294,7 @@ 028A37222ADFF3F7008CA604 /* Frameworks */, 028A37232ADFF3F7008CA604 /* Resources */, 8A74692D666D8FF13F7BA64F /* [CP] Copy Pods Resources */, + CE7CAF372CC1560900E0AC9D /* Embed Frameworks */, ); buildRules = ( ); @@ -317,6 +335,9 @@ uk, ); mainGroup = 028A37132ADFF3F7008CA604; + packageReferences = ( + CEB1E2652CC14E6400921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */, + ); productRefGroup = 028A371E2ADFF3F7008CA604 /* Products */; projectDirPath = ""; projectRoot = ""; @@ -1504,6 +1525,30 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + CEB1E2652CC14E6400921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/openedx/openedx-app-foundation-ios/"; + requirement = { + kind = exactVersion; + version = 1.0.0; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + CE7CAF342CC1560900E0AC9D /* OEXFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = CEB1E2652CC14E6400921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */; + productName = OEXFoundation; + }; + CEB1E2662CC14E6400921517 /* OEXFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = CEB1E2652CC14E6400921517 /* XCRemoteSwiftPackageReference "openedx-app-foundation-ios" */; + productName = OEXFoundation; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 028A37142ADFF3F7008CA604 /* Project object */; } diff --git a/WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift b/WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift index dca08a91e..24726cef8 100644 --- a/WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift +++ b/WhatsNew/WhatsNewTests/WhatsNewMock.generated.swift @@ -13,6 +13,7 @@ import WhatsNew import Foundation import SwiftUI import Combine +import OEXFoundation // MARK: - WhatsNewAnalytics diff --git a/config_script/process_config.py b/config_script/process_config.py index c03e3d8f4..1fdea0270 100644 --- a/config_script/process_config.py +++ b/config_script/process_config.py @@ -247,14 +247,6 @@ def add_microsoft_config(self, config, plist): scheme = ["msauth." + bundle_identifier] self.add_url_scheme(scheme, plist, False) self.add_application_query_schemes(["msauthv2", "msauthv3"], plist) - - def add_fullstory_config(self, config, plist): - fullstory = config.get('FULLSTORY', {}) - enabled = fullstory.get('ENABLED') - orgID = fullstory.get('ORG_ID') - - if enabled and orgID: - plist["FullStory"] = {"orgID": orgID} def update_info_plist(self, plist_data, plist_path): if not plist_path: @@ -311,7 +303,6 @@ def process_plist_files(configuration_manager, plist_manager, config): 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) - configuration_manager.add_fullstory_config(config, info_plist_content) configuration_manager.update_info_plist(info_plist_content, info_plist_path) diff --git a/config_script/whitelabel.py b/config_script/whitelabel.py index 24d1b01a9..13bdc1c1c 100644 --- a/config_script/whitelabel.py +++ b/config_script/whitelabel.py @@ -564,11 +564,11 @@ def set_flags_from_mobile_config(self): # iterate for all configurations for name, config in configurations.items(): if 'env_config' in config: - # get folder name for mobile config for current configuration by env_config + # get folder name for mobile config for current configuration by env_config config_folder = config_settings.get(self.CONFIG_MAPPINGS, {}).get(config['env_config']) if config_folder: - # replace fullstory flag - project_file_string = self.replace_fullstory_flag(project_file_string, config_directory, name, config_folder, errors_texts) + # example of usage + # project_file_string = self.replace_fullstory_flag(project_file_string, config_directory, name, config_folder, errors_texts) else: logging.error("Config folder for '"+config['env_config']+"' is not defined in config_settings.yaml->config_mapping") else: @@ -587,20 +587,20 @@ def set_flags_from_mobile_config(self): else: logging.error("Mobile config directory not found") - def replace_fullstory_flag(self, project_file_string, config_directory, config_name, config_folder, errors_texts): - # get mobile config - mobile_config = self.get_mobile_config(config_directory, config_folder, errors_texts) - if mobile_config: - # get FULLSTORY settings from mobile config - fullstory_config = mobile_config.get('FULLSTORY', {}) - if fullstory_config: - fullstory_config_enabled = fullstory_config.get('ENABLED') - fullstory_string = "FULLSTORY_ENABLED = YES;" if fullstory_config_enabled else "FULLSTORY_ENABLED = NO;" - fullstory_regex = "FULLSTORY_ENABLED = .*;" - # serach by regex and replace - project_file_string = self.replace_parameter_for_build_config(project_file_string, config_name, fullstory_string, fullstory_regex, errors_texts) - return project_file_string - +# def replace_fullstory_flag(self, project_file_string, config_directory, config_name, config_folder, errors_texts): +# # get mobile config +# mobile_config = self.get_mobile_config(config_directory, config_folder, errors_texts) +# if mobile_config: +# # get FULLSTORY settings from mobile config +# fullstory_config = mobile_config.get('FULLSTORY', {}) +# if fullstory_config: +# fullstory_config_enabled = fullstory_config.get('ENABLED') +# fullstory_string = "FULLSTORY_ENABLED = YES;" if fullstory_config_enabled else "FULLSTORY_ENABLED = NO;" +# fullstory_regex = "FULLSTORY_ENABLED = .*;" +# # serach by regex and replace +# project_file_string = self.replace_parameter_for_build_config(project_file_string, config_name, fullstory_string, fullstory_regex, errors_texts) +# return project_file_string +# def main(): """ Parse the command line arguments, and pass them to WhitelabelApp. diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 5393609bf..aa3a39f5b 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -38,6 +38,7 @@ lane :unit_tests do run_tests( workspace: "OpenEdX.xcworkspace", device: "iPhone 16", - scheme: "OpenEdXDev" + scheme: "OpenEdXDev", + xcargs: "-skipPackagePluginValidation -skipMacroValidation" # Ignore swiftLint plugin validation ) end From 21e94b6509b9649f8f37a896220d79a80e191d92 Mon Sep 17 00:00:00 2001 From: Anton Yarmolenko Date: Thu, 7 Nov 2024 15:00:16 +0100 Subject: [PATCH 54/55] fix: Part #4 sync to upstream (#540) * fix: SE device UI issues (#39) * fix: se device * fix: course image * fix: small course * fix: header for ios 15 * fix: double nav bar * chore: added trophy assets * fix: fixed all courses screen * fix: ipad course dashboard * chore: fixed dates banner padding * fix: ipad upgrade view * chore: fix for rotation bug * fix: scroll on home tab * chore: update introspect for future versions * chore: rename name of file icon * chore: delete unneeded IAP icon * chore: delete IAP part * chore: delete IAP part * chore: fix after merge * chore: started to add courseRawImage * chore: delete IAP part * fix: after merge * chore: deleted unsupported ios 15 modifiers * chore: added courseRawImage * chore: moved progress to correct place * chore: fix broken tests --------- Co-authored-by: Vadim Kuznetsov Co-authored-by: Anton Yarmolenko <37253+rnr@users.noreply.github.com> --- Core/Core/Data/Model/Data_Discovery.swift | 1 + Core/Core/Data/Model/Data_Enrollments.swift | 1 + .../Data/Model/Data_PrimaryEnrollment.swift | 1 + Core/Core/Domain/Model/CourseItem.swift | 3 + Core/Core/View/Base/CourseCellView.swift | 1 + Core/Core/View/Base/DynamicOffsetView.swift | 10 +- .../CourseCoreModel.xcdatamodel/contents | 3 +- .../Container/CourseContainerView.swift | 27 +- .../Outline/ContinueWithView.swift | 1 - .../Outline/CourseOutlineView.swift | 132 ++++----- .../Subviews/CourseHeaderView.swift | 16 +- .../Dashboard/Data/DashboardRepository.swift | 3 + .../DashboardCoreModel.xcdatamodel/contents | 3 +- .../Presentation/AllCoursesView.swift | 111 ++++---- .../Presentation/DashboardRouter.swift | 2 + .../Presentation/ListDashboardView.swift | 1 + .../PrimaryCourseDashboardView.swift | 4 + .../AllCoursesViewModelTests.swift | 3 + .../DashboardViewModelTests.swift | 4 + ...PrimaryCourseDashboardViewModelTests.swift | 1 + .../Discovery/Data/DiscoveryRepository.swift | 9 +- .../Discovery/Data/Model/CourseDetails.swift | 6 +- .../Model/Data_CourseDetailsResponse.swift | 4 +- .../DiscoveryCoreModel.xcdatamodel/contents | 4 +- .../Presentation/DiscoveryRouter.swift | 2 + .../NativeDiscovery/CourseDetailsView.swift | 1 + .../DiscoveryWebviewViewModel.swift | 1 + .../WebPrograms/ProgramWebviewViewModel.swift | 1 + .../CourseDetailsViewModelTests.swift | 6 +- .../DiscoveryViewModelTests.swift | 6 + .../Presentation/SearchViewModelTests.swift | 2 + .../DiscussionTopicsView.swift | 256 +++++++++--------- OpenEdX/Data/CoursePersistence.swift | 1 + OpenEdX/Data/DashboardPersistence.swift | 3 + OpenEdX/Data/DiscoveryPersistence.swift | 6 +- .../DeepLinkRouter/DeepLinkRouter.swift | 1 + OpenEdX/Managers/PipManager.swift | 1 + OpenEdX/Router.swift | 6 +- 38 files changed, 372 insertions(+), 272 deletions(-) diff --git a/Core/Core/Data/Model/Data_Discovery.swift b/Core/Core/Data/Model/Data_Discovery.swift index e5e4d01d7..1d21e1b6d 100644 --- a/Core/Core/Data/Model/Data_Discovery.swift +++ b/Core/Core/Data/Model/Data_Discovery.swift @@ -116,6 +116,7 @@ public extension DataLayer.DiscoveryResponce { courseID: $0.courseID ?? "", numPages: pagination.numPages, coursesCount: pagination.count, + courseRawImage: $0.media.image?.raw, progressEarned: 0, progressPossible: 0) }) diff --git a/Core/Core/Data/Model/Data_Enrollments.swift b/Core/Core/Data/Model/Data_Enrollments.swift index 527a69daa..5b6f834b6 100644 --- a/Core/Core/Data/Model/Data_Enrollments.swift +++ b/Core/Core/Data/Model/Data_Enrollments.swift @@ -260,6 +260,7 @@ public extension DataLayer.CourseEnrollments { courseID: course.id, numPages: enrollments.numPages ?? 1, coursesCount: enrollments.count ?? 0, + courseRawImage: course.media.courseImage?.url, progressEarned: 0, progressPossible: 0 ) diff --git a/Core/Core/Data/Model/Data_PrimaryEnrollment.swift b/Core/Core/Data/Model/Data_PrimaryEnrollment.swift index cbf70fc81..16af30373 100644 --- a/Core/Core/Data/Model/Data_PrimaryEnrollment.swift +++ b/Core/Core/Data/Model/Data_PrimaryEnrollment.swift @@ -262,6 +262,7 @@ public extension DataLayer.PrimaryEnrollment { courseID: enrollment.course.id, numPages: numPages, coursesCount: count, + courseRawImage: enrollment.course.media.image?.raw, progressEarned: enrollment.progress?.assignmentsCompleted ?? 0, progressPossible: enrollment.progress?.totalAssignmentsCount ?? 0 ) diff --git a/Core/Core/Domain/Model/CourseItem.swift b/Core/Core/Domain/Model/CourseItem.swift index 67647e038..19bd1f612 100644 --- a/Core/Core/Domain/Model/CourseItem.swift +++ b/Core/Core/Domain/Model/CourseItem.swift @@ -20,6 +20,7 @@ public struct CourseItem: Hashable { public let courseID: String public let numPages: Int public let coursesCount: Int + public let courseRawImage: String? public let progressEarned: Int public let progressPossible: Int @@ -35,6 +36,7 @@ public struct CourseItem: Hashable { courseID: String, numPages: Int, coursesCount: Int, + courseRawImage: String?, progressEarned: Int, progressPossible: Int) { self.name = name @@ -49,6 +51,7 @@ public struct CourseItem: Hashable { self.courseID = courseID self.numPages = numPages self.coursesCount = coursesCount + self.courseRawImage = courseRawImage self.progressEarned = progressEarned self.progressPossible = progressPossible } diff --git a/Core/Core/View/Base/CourseCellView.swift b/Core/Core/View/Base/CourseCellView.swift index 35f0c5d61..37dada165 100644 --- a/Core/Core/View/Base/CourseCellView.swift +++ b/Core/Core/View/Base/CourseCellView.swift @@ -138,6 +138,7 @@ struct CourseCellView_Previews: PreviewProvider { courseID: "1", numPages: 1, coursesCount: 10, + courseRawImage: nil, progressEarned: 4, progressPossible: 10 ) diff --git a/Core/Core/View/Base/DynamicOffsetView.swift b/Core/Core/View/Base/DynamicOffsetView.swift index da1bdf984..1647af921 100644 --- a/Core/Core/View/Base/DynamicOffsetView.swift +++ b/Core/Core/View/Base/DynamicOffsetView.swift @@ -29,6 +29,7 @@ public struct DynamicOffsetView: View { @Environment(\.isHorizontal) private var isHorizontal + @State private var isOnTheScreen: Bool = false public init( coordinate: Binding, collapsed: Binding, @@ -45,6 +46,9 @@ public struct DynamicOffsetView: View { .frame(height: collapseHeight) .overlay( GeometryReader { geometry -> Color in + if !isOnTheScreen { + return .clear + } guard idiom != .pad else { return .clear } @@ -59,8 +63,12 @@ public struct DynamicOffsetView: View { } ) .onAppear { + isOnTheScreen = true changeCollapsedHeight(collapsed: collapsed, isHorizontal: isHorizontal) } + .onDisappear { + isOnTheScreen = false + } .onChange(of: collapsed) { collapsed in if !collapsed { changeCollapsedHeight(collapsed: collapsed, isHorizontal: isHorizontal) @@ -87,7 +95,7 @@ public struct DynamicOffsetView: View { collapseHeight = collapsedVerticalHeight } } else { - collapseHeight = 240 + collapseHeight = expandedHeight } viewHeight = collapseHeight } diff --git a/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents b/Course/Course/Data/Persistence/CourseCoreModel.xcdatamodeld/CourseCoreModel.xcdatamodel/contents index cb4d84738..1c6c4c93f 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 @@ - + @@ -68,6 +68,7 @@ + diff --git a/Course/Course/Presentation/Container/CourseContainerView.swift b/Course/Course/Presentation/Container/CourseContainerView.swift index 65ca348d2..725f98ace 100644 --- a/Course/Course/Presentation/Container/CourseContainerView.swift +++ b/Course/Course/Presentation/Container/CourseContainerView.swift @@ -10,6 +10,7 @@ import Core import Discussion import Swinject import Theme +@_spi(Advanced) import SwiftUIIntrospect public struct CourseContainerView: View { @@ -30,6 +31,7 @@ public struct CourseContainerView: View { private var idiom: UIUserInterfaceIdiom { UIDevice.current.userInterfaceIdiom } private let coordinateBoundaryLower: CGFloat = -115 + private let courseRawImage: String? private var coordinateBoundaryHigher: CGFloat { let topInset = UIApplication.shared.windowInsets.top @@ -52,7 +54,8 @@ public struct CourseContainerView: View { viewModel: CourseContainerViewModel, courseDatesViewModel: CourseDatesViewModel, courseID: String, - title: String + title: String, + courseRawImage: String? ) { self.viewModel = viewModel Task { @@ -68,6 +71,7 @@ public struct CourseContainerView: View { self.courseID = courseID self.title = title self.courseDatesViewModel = courseDatesViewModel + self.courseRawImage = courseRawImage } public var body: some View { @@ -108,22 +112,24 @@ public struct CourseContainerView: View { collapsed: $collapsed, containerWidth: proxy.size.width, animationNamespace: animationNamespace, - isAnimatingForTap: $isAnimatingForTap + isAnimatingForTap: $isAnimatingForTap, + courseRawImage: courseRawImage ) } .offset( y: ignoreOffset ? (collapsed ? coordinateBoundaryLower : .zero) : ((coordinateBoundaryLower...coordinateBoundaryHigher).contains(coordinate) - ? coordinate + ? (collapsed ? coordinateBoundaryLower : coordinate) : (collapsed ? coordinateBoundaryLower : .zero)) ) backButton(containerWidth: proxy.size.width) } - }.ignoresSafeArea(edges: idiom == .pad ? .leading : .top) - .onAppear { - self.collapsed = isHorizontal - } + } + .ignoresSafeArea(edges: idiom == .pad ? .leading : .top) + .onAppear { + self.collapsed = isHorizontal + } } } @@ -282,7 +288,7 @@ public struct CourseContainerView: View { } } .tabViewStyle(.page(indexDisplayMode: .never)) - .introspect(.scrollView, on: .iOS(.v15, .v16, .v17), customize: { tabView in + .introspect(.scrollView, on: .iOS(.v16...), customize: { tabView in tabView.isScrollEnabled = false }) .onFirstAppear { @@ -379,11 +385,12 @@ struct CourseScreensView_Previews: PreviewProvider { config: ConfigMock(), courseID: "1", courseName: "a", - analytics: CourseAnalyticsMock(), + analytics: CourseAnalyticsMock(), calendarManager: CalendarManagerMock() ), courseID: "", - title: "Title of Course" + title: "Title of Course", + courseRawImage: nil ) } } diff --git a/Course/Course/Presentation/Outline/ContinueWithView.swift b/Course/Course/Presentation/Outline/ContinueWithView.swift index e8345bbb0..9da8b7a6c 100644 --- a/Course/Course/Presentation/Outline/ContinueWithView.swift +++ b/Course/Course/Presentation/Outline/ContinueWithView.swift @@ -41,7 +41,6 @@ struct ContinueWithView: View { .frame(width: 200) } .padding(.horizontal, 24) - .padding(.top, 32) } else { VStack(alignment: .leading) { ContinueTitle(vertical: courseContinueUnit) diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index d20c4de00..37e14b589 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -61,79 +61,81 @@ public struct CourseOutlineView: View { GeometryReader { proxy in VStack(alignment: .center) { ScrollView { - DynamicOffsetView( - coordinate: $coordinate, - collapsed: $collapsed, - viewHeight: $viewHeight - ) - RefreshProgressView(isShowRefresh: $viewModel.isShowRefresh) - VStack(alignment: .leading) { - - if isVideo, - viewModel.isShowProgress == false { - downloadQualityBars(proxy: proxy) - } - certificateView - - if viewModel.courseStructure == nil, - viewModel.isShowProgress == false, - !isVideo { - FullScreenErrorView( - type: .noContent( - CourseLocalization.Error.coursewareUnavailable, - image: CoreAssets.information.swiftUIImage - ) - ) - .frame(maxWidth: .infinity) - .frame(height: proxy.size.height - viewHeight) - } else { - if let continueWith = viewModel.continueWith, - let courseStructure = viewModel.courseStructure, - !isVideo { - let chapter = courseStructure.childs[continueWith.chapterIndex] - let sequential = chapter.childs[continueWith.sequentialIndex] - let continueUnit = sequential.childs[continueWith.verticalIndex] - - ContinueWithView( - data: continueWith, - courseContinueUnit: continueUnit - ) { - viewModel.openLastVisitedBlock() - } + VStack(spacing: 0) { + DynamicOffsetView( + coordinate: $coordinate, + collapsed: $collapsed, + viewHeight: $viewHeight + ) + RefreshProgressView(isShowRefresh: $viewModel.isShowRefresh) + VStack(alignment: .leading) { + + if isVideo, + viewModel.isShowProgress == false { + downloadQualityBars(proxy: proxy) } + certificateView - if let course = isVideo - ? viewModel.courseVideosStructure - : viewModel.courseStructure { - - if !isVideo, - let progress = course.courseProgress, - progress.totalAssignmentsCount != 0 { - CourseProgressView(progress: progress) - .padding(.horizontal, 24) - .padding(.top, 16) - .padding(.bottom, 8) - } - - // MARK: - Sections - CustomDisclosureGroup( - isVideo: isVideo, - course: course, - proxy: proxy, - viewModel: viewModel + if viewModel.courseStructure == nil, + viewModel.isShowProgress == false, + !isVideo { + FullScreenErrorView( + type: .noContent( + CourseLocalization.Error.coursewareUnavailable, + image: CoreAssets.information.swiftUIImage + ) ) + .frame(maxWidth: .infinity) + .frame(height: proxy.size.height - viewHeight) } else { - if let courseStart = viewModel.courseStart { - Text(courseStart > Date() ? CourseLocalization.Outline.courseHasntStarted : "") - .frame(maxWidth: .infinity) - .frame(maxHeight: .infinity) - .padding(.top, 100) + if let continueWith = viewModel.continueWith, + let courseStructure = viewModel.courseStructure, + !isVideo { + let chapter = courseStructure.childs[continueWith.chapterIndex] + let sequential = chapter.childs[continueWith.sequentialIndex] + let continueUnit = sequential.childs[continueWith.verticalIndex] + + ContinueWithView( + data: continueWith, + courseContinueUnit: continueUnit + ) { + viewModel.openLastVisitedBlock() + } + } + + if let course = isVideo + ? viewModel.courseVideosStructure + : viewModel.courseStructure { + + if !isVideo, + let progress = course.courseProgress, + progress.totalAssignmentsCount != 0 { + CourseProgressView(progress: progress) + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 8) + } + + // MARK: - Sections + CustomDisclosureGroup( + isVideo: isVideo, + course: course, + proxy: proxy, + viewModel: viewModel + ) + } else { + if let courseStart = viewModel.courseStart { + Text(courseStart > Date() ? CourseLocalization.Outline.courseHasntStarted : "") + .frame(maxWidth: .infinity) + .frame(maxHeight: .infinity) + .padding(.top, 100) + } + Spacer(minLength: viewHeight < 200 ? 200 : viewHeight) } } - Spacer(minLength: 200) } + .frameLimit(width: proxy.size.width) } - .frameLimit(width: proxy.size.width) } .refreshable { Task { diff --git a/Course/Course/Presentation/Subviews/CourseHeaderView.swift b/Course/Course/Presentation/Subviews/CourseHeaderView.swift index 26e8ad38e..3f604e7df 100644 --- a/Course/Course/Presentation/Subviews/CourseHeaderView.swift +++ b/Course/Course/Presentation/Subviews/CourseHeaderView.swift @@ -24,6 +24,7 @@ struct CourseHeaderView: View { private let collapsedVerticalHeight: CGFloat = 260 private let expandedHeight: CGFloat = 300 + private let courseRawImage: String? private enum GeometryName { case backButton case topTabBar @@ -38,7 +39,8 @@ struct CourseHeaderView: View { collapsed: Binding, containerWidth: CGFloat, animationNamespace: Namespace.ID, - isAnimatingForTap: Binding + isAnimatingForTap: Binding, + courseRawImage: String? ) { self.viewModel = viewModel self.title = title @@ -46,14 +48,15 @@ struct CourseHeaderView: View { self.containerWidth = containerWidth self.animationNamespace = animationNamespace self._isAnimatingForTap = isAnimatingForTap + self.courseRawImage = courseRawImage } var body: some View { ZStack(alignment: .bottomLeading) { ScrollView { - if let banner = viewModel.courseStructure?.media.image.raw + if let banner = (courseRawImage ?? viewModel.courseStructure?.media.image.raw)? .addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) { - KFImage(URL(string: viewModel.config.baseURL.absoluteString + banner)) + KFImage(courseBannerURL(for: banner)) .onFailureImage(CoreAssets.noCourseImage.image) .resizable() .aspectRatio(contentMode: .fill) @@ -159,6 +162,13 @@ struct CourseHeaderView: View { .ignoresSafeArea(edges: .top) } + private func courseBannerURL(for path: String) -> URL? { + if path.contains("http://") || path.contains("https://") { + return URL(string: path) + } + return URL(string: viewModel.config.baseURL.absoluteString + path) + } + private func courseMenuBar(containerWidth: CGFloat) -> some View { ScrollSlidingTabBar( selection: $viewModel.selection, diff --git a/Dashboard/Dashboard/Data/DashboardRepository.swift b/Dashboard/Dashboard/Data/DashboardRepository.swift index 61f9e3f41..9487caab4 100644 --- a/Dashboard/Dashboard/Data/DashboardRepository.swift +++ b/Dashboard/Dashboard/Data/DashboardRepository.swift @@ -99,6 +99,7 @@ class DashboardRepositoryMock: DashboardRepositoryProtocol { courseID: "course_id_\(i)", numPages: 1, coursesCount: 0, + courseRawImage: nil, progressEarned: 0, progressPossible: 0 ) @@ -127,6 +128,7 @@ class DashboardRepositoryMock: DashboardRepositoryProtocol { courseID: "course_id_\(i)", numPages: 1, coursesCount: 0, + courseRawImage: nil, progressEarned: 4, progressPossible: 10 ) @@ -181,6 +183,7 @@ class DashboardRepositoryMock: DashboardRepositoryProtocol { courseID: "course_id_\(i)", numPages: 1, coursesCount: 0, + courseRawImage: nil, progressEarned: 4, progressPossible: 10 ) diff --git a/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents b/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents index 65dbea3a0..48a5a25c6 100644 --- a/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents +++ b/Dashboard/Dashboard/Data/Persistence/DashboardCoreModel.xcdatamodeld/DashboardCoreModel.xcdatamodel/contents @@ -1,5 +1,5 @@ - + @@ -20,6 +20,7 @@ + diff --git a/Dashboard/Dashboard/Presentation/AllCoursesView.swift b/Dashboard/Dashboard/Presentation/AllCoursesView.swift index bdd6a6b77..f35299372 100644 --- a/Dashboard/Dashboard/Presentation/AllCoursesView.swift +++ b/Dashboard/Dashboard/Presentation/AllCoursesView.swift @@ -53,65 +53,68 @@ public struct AllCoursesView: View { learnTitleAndSearch() .frameLimit(width: proxy.size.width) ScrollView { - CategoryFilterView(selectedOption: $viewModel.selectedMenu) - .disabled(viewModel.fetchInProgress) - .frameLimit(width: proxy.size.width) - if let myEnrollments = viewModel.myEnrollments { - let useRelativeDates = viewModel.storage.useRelativeDates - LazyVGrid(columns: columns(), spacing: 15) { - ForEach( - Array(myEnrollments.courses.enumerated()), - id: \.offset - ) { index, course in - Button(action: { - viewModel.trackDashboardCourseClicked( - courseID: course.courseID, - courseName: course.name - ) - router.showCourseScreens( - courseID: course.courseID, - hasAccess: course.hasAccess, - courseStart: course.courseStart, - courseEnd: course.courseEnd, - enrollmentStart: course.enrollmentStart, - enrollmentEnd: course.enrollmentEnd, - title: course.name, - showDates: false, - lastVisitedBlockID: nil - ) - }, label: { - CourseCardView( - courseName: course.name, - courseImage: course.imageURL, - progressEarned: course.progressEarned, - progressPossible: course.progressPossible, - courseStartDate: course.courseStart, - courseEndDate: course.courseEnd, - hasAccess: course.hasAccess, - showProgress: true, - useRelativeDates: useRelativeDates - ).padding(8) - }) - .accessibilityIdentifier("course_item") - .onAppear { - Task { - await viewModel.getMyCoursesPagination(index: index) + VStack(spacing: 0) { + CategoryFilterView(selectedOption: $viewModel.selectedMenu) + .disabled(viewModel.fetchInProgress) + .frameLimit(width: proxy.size.width) + if let myEnrollments = viewModel.myEnrollments { + let useRelativeDates = viewModel.storage.useRelativeDates + LazyVGrid(columns: columns(), spacing: 15) { + ForEach( + Array(myEnrollments.courses.enumerated()), + id: \.offset + ) { index, course in + Button(action: { + viewModel.trackDashboardCourseClicked( + courseID: course.courseID, + courseName: course.name + ) + router.showCourseScreens( + courseID: course.courseID, + hasAccess: course.hasAccess, + courseStart: course.courseStart, + courseEnd: course.courseEnd, + enrollmentStart: course.enrollmentStart, + enrollmentEnd: course.enrollmentEnd, + title: course.name, + courseRawImage: course.imageURL, + showDates: false, + lastVisitedBlockID: nil + ) + }, label: { + CourseCardView( + courseName: course.name, + courseImage: course.imageURL, + progressEarned: course.progressEarned, + progressPossible: course.progressPossible, + courseStartDate: course.courseStart, + courseEndDate: course.courseEnd, + hasAccess: course.hasAccess, + showProgress: true, + useRelativeDates: useRelativeDates + ).padding(8) + }) + .accessibilityIdentifier("course_item") + .onAppear { + Task { + await viewModel.getMyCoursesPagination(index: index) + } } } + .padding(10) + .frameLimit(width: proxy.size.width) } } - .padding(10) - .frameLimit(width: proxy.size.width) - } - // MARK: - ProgressBar - if viewModel.nextPage <= viewModel.totalPages, !viewModel.refresh { - VStack(alignment: .center) { - ProgressBar(size: 40, lineWidth: 8) - .padding(.top, 20) - }.frame(maxWidth: .infinity, - maxHeight: .infinity) + // MARK: - ProgressBar + if viewModel.nextPage <= viewModel.totalPages, !viewModel.refresh { + VStack(alignment: .center) { + ProgressBar(size: 40, lineWidth: 8) + .padding(.top, 20) + }.frame(maxWidth: .infinity, + maxHeight: .infinity) + } + VStack {}.frame(height: 40) } - VStack {}.frame(height: 40) } .refreshable { Task { diff --git a/Dashboard/Dashboard/Presentation/DashboardRouter.swift b/Dashboard/Dashboard/Presentation/DashboardRouter.swift index 0e66ff2a8..0d38f3199 100644 --- a/Dashboard/Dashboard/Presentation/DashboardRouter.swift +++ b/Dashboard/Dashboard/Presentation/DashboardRouter.swift @@ -17,6 +17,7 @@ public protocol DashboardRouter: BaseRouter { enrollmentStart: Date?, enrollmentEnd: Date?, title: String, + courseRawImage: String?, showDates: Bool, lastVisitedBlockID: String?) @@ -41,6 +42,7 @@ public class DashboardRouterMock: BaseRouterMock, DashboardRouter { enrollmentStart: Date?, enrollmentEnd: Date?, title: String, + courseRawImage: String?, showDates: Bool, lastVisitedBlockID: String?) {} diff --git a/Dashboard/Dashboard/Presentation/ListDashboardView.swift b/Dashboard/Dashboard/Presentation/ListDashboardView.swift index d402ce41c..d5d925bd0 100644 --- a/Dashboard/Dashboard/Presentation/ListDashboardView.swift +++ b/Dashboard/Dashboard/Presentation/ListDashboardView.swift @@ -83,6 +83,7 @@ public struct ListDashboardView: View { enrollmentStart: course.enrollmentStart, enrollmentEnd: course.enrollmentEnd, title: course.name, + courseRawImage: course.courseRawImage, showDates: false, lastVisitedBlockID: nil ) diff --git a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift index 718b0ceb5..e7af096b1 100644 --- a/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift +++ b/Dashboard/Dashboard/Presentation/PrimaryCourseDashboardView.swift @@ -84,6 +84,7 @@ public struct PrimaryCourseDashboardView: View { enrollmentStart: nil, enrollmentEnd: nil, title: primary.name, + courseRawImage: primary.courseBanner, showDates: lastVisitedBlockID == nil, lastVisitedBlockID: lastVisitedBlockID ) @@ -97,6 +98,7 @@ public struct PrimaryCourseDashboardView: View { enrollmentStart: nil, enrollmentEnd: nil, title: primary.name, + courseRawImage: primary.courseBanner, showDates: false, lastVisitedBlockID: nil ) @@ -110,6 +112,7 @@ public struct PrimaryCourseDashboardView: View { enrollmentStart: nil, enrollmentEnd: nil, title: primary.name, + courseRawImage: primary.courseBanner, showDates: false, lastVisitedBlockID: primary.lastVisitedBlockID ) @@ -223,6 +226,7 @@ public struct PrimaryCourseDashboardView: View { enrollmentStart: course.enrollmentStart, enrollmentEnd: course.enrollmentEnd, title: course.name, + courseRawImage: course.imageURL, showDates: false, lastVisitedBlockID: nil ) diff --git a/Dashboard/DashboardTests/Presentation/AllCoursesViewModelTests.swift b/Dashboard/DashboardTests/Presentation/AllCoursesViewModelTests.swift index 625120e66..abd7c7e6e 100644 --- a/Dashboard/DashboardTests/Presentation/AllCoursesViewModelTests.swift +++ b/Dashboard/DashboardTests/Presentation/AllCoursesViewModelTests.swift @@ -58,6 +58,7 @@ final class AllCoursesViewModelTests: XCTestCase { courseID: "2", numPages: 1, coursesCount: 3, + courseRawImage: nil, progressEarned: 0, progressPossible: 2 ), @@ -74,6 +75,7 @@ final class AllCoursesViewModelTests: XCTestCase { courseID: "3", numPages: 1, coursesCount: 3, + courseRawImage: nil, progressEarned: 0, progressPossible: 2 ), @@ -90,6 +92,7 @@ final class AllCoursesViewModelTests: XCTestCase { courseID: "4", numPages: 1, coursesCount: 3, + courseRawImage: nil, progressEarned: 0, progressPossible: 2 ) diff --git a/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift b/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift index a5fb4e9b7..e053c19c7 100644 --- a/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift +++ b/Dashboard/DashboardTests/Presentation/DashboardViewModelTests.swift @@ -38,6 +38,7 @@ final class ListDashboardViewModelTests: XCTestCase { courseID: "123", numPages: 2, coursesCount: 2, + courseRawImage: nil, progressEarned: 0, progressPossible: 0), CourseItem(name: "Test2", @@ -52,6 +53,7 @@ final class ListDashboardViewModelTests: XCTestCase { courseID: "1243", numPages: 1, coursesCount: 2, + courseRawImage: nil, progressEarned: 0, progressPossible: 0) ] @@ -92,6 +94,7 @@ final class ListDashboardViewModelTests: XCTestCase { courseID: "123", numPages: 2, coursesCount: 2, + courseRawImage: nil, progressEarned: 0, progressPossible: 0), CourseItem(name: "Test2", @@ -106,6 +109,7 @@ final class ListDashboardViewModelTests: XCTestCase { courseID: "1243", numPages: 1, coursesCount: 2, + courseRawImage: nil, progressEarned: 0, progressPossible: 0) ] diff --git a/Dashboard/DashboardTests/Presentation/PrimaryCourseDashboardViewModelTests.swift b/Dashboard/DashboardTests/Presentation/PrimaryCourseDashboardViewModelTests.swift index c4083e705..0f6aff8c5 100644 --- a/Dashboard/DashboardTests/Presentation/PrimaryCourseDashboardViewModelTests.swift +++ b/Dashboard/DashboardTests/Presentation/PrimaryCourseDashboardViewModelTests.swift @@ -61,6 +61,7 @@ final class PrimaryCourseDashboardViewModelTests: XCTestCase { courseID: "2", numPages: 1, coursesCount: 3, + courseRawImage: nil, progressEarned: 0, progressPossible: 2 ) diff --git a/Discovery/Discovery/Data/DiscoveryRepository.swift b/Discovery/Discovery/Data/DiscoveryRepository.swift index aa0b71978..d08571071 100644 --- a/Discovery/Discovery/Data/DiscoveryRepository.swift +++ b/Discovery/Discovery/Data/DiscoveryRepository.swift @@ -95,7 +95,8 @@ class DiscoveryRepositoryMock: DiscoveryRepositoryProtocol { isEnrolled: false, overviewHTML: "Course description

Lorem ipsum", courseBannerURL: "courseBannerURL", - courseVideoURL: nil + courseVideoURL: nil, + courseRawImage: nil ) } @@ -112,7 +113,8 @@ class DiscoveryRepositoryMock: DiscoveryRepositoryProtocol { isEnrolled: false, overviewHTML: "Course description

Lorem ipsum", courseBannerURL: "courseBannerURL", - courseVideoURL: nil + courseVideoURL: nil, + courseRawImage: nil ) } @@ -136,6 +138,7 @@ class DiscoveryRepositoryMock: DiscoveryRepositoryProtocol { enrollmentEnd: nil, courseID: "course_id_\(i)", numPages: 1, coursesCount: 10, + courseRawImage: nil, progressEarned: 0, progressPossible: 0 ) @@ -160,6 +163,7 @@ class DiscoveryRepositoryMock: DiscoveryRepositoryProtocol { enrollmentEnd: nil, courseID: "course_id_\(i)", numPages: 1, coursesCount: 10, + courseRawImage: nil, progressEarned: 0, progressPossible: 0 ) @@ -185,6 +189,7 @@ class DiscoveryRepositoryMock: DiscoveryRepositoryProtocol { courseID: "course_id_\(i)", numPages: 1, coursesCount: 10, + courseRawImage: nil, progressEarned: 0, progressPossible: 0 ) diff --git a/Discovery/Discovery/Data/Model/CourseDetails.swift b/Discovery/Discovery/Data/Model/CourseDetails.swift index 6769aff53..fb67340aa 100644 --- a/Discovery/Discovery/Data/Model/CourseDetails.swift +++ b/Discovery/Discovery/Data/Model/CourseDetails.swift @@ -20,6 +20,7 @@ public struct CourseDetails { public var overviewHTML: String public let courseBannerURL: String public let courseVideoURL: String? + public let courseRawImage: String? public init(courseID: String, org: String, @@ -32,7 +33,9 @@ public struct CourseDetails { isEnrolled: Bool, overviewHTML: String, courseBannerURL: String, - courseVideoURL: String?) { + courseVideoURL: String?, + courseRawImage: String? + ) { self.courseID = courseID self.org = org self.courseTitle = courseTitle @@ -45,5 +48,6 @@ public struct CourseDetails { self.overviewHTML = overviewHTML self.courseBannerURL = courseBannerURL self.courseVideoURL = courseVideoURL + self.courseRawImage = courseRawImage } } diff --git a/Discovery/Discovery/Data/Model/Data_CourseDetailsResponse.swift b/Discovery/Discovery/Data/Model/Data_CourseDetailsResponse.swift index 1047727e8..9b9e2522b 100644 --- a/Discovery/Discovery/Data/Model/Data_CourseDetailsResponse.swift +++ b/Discovery/Discovery/Data/Model/Data_CourseDetailsResponse.swift @@ -75,6 +75,8 @@ public extension DataLayer.CourseDetailsResponse { isEnrolled: isEnrolled, overviewHTML: overview, courseBannerURL: imageURL, - courseVideoURL: media.courseVideo?.url) + courseVideoURL: media.courseVideo?.url, + courseRawImage: media.image?.raw + ) } } diff --git a/Discovery/Discovery/Data/Persistence/DiscoveryCoreModel.xcdatamodeld/DiscoveryCoreModel.xcdatamodel/contents b/Discovery/Discovery/Data/Persistence/DiscoveryCoreModel.xcdatamodeld/DiscoveryCoreModel.xcdatamodel/contents index 2c838b0dd..f508a975a 100644 --- a/Discovery/Discovery/Data/Persistence/DiscoveryCoreModel.xcdatamodeld/DiscoveryCoreModel.xcdatamodel/contents +++ b/Discovery/Discovery/Data/Persistence/DiscoveryCoreModel.xcdatamodeld/DiscoveryCoreModel.xcdatamodel/contents @@ -1,10 +1,11 @@ - + + @@ -23,6 +24,7 @@ + diff --git a/Discovery/Discovery/Presentation/DiscoveryRouter.swift b/Discovery/Discovery/Presentation/DiscoveryRouter.swift index 4416d9659..6c9651c78 100644 --- a/Discovery/Discovery/Presentation/DiscoveryRouter.swift +++ b/Discovery/Discovery/Presentation/DiscoveryRouter.swift @@ -26,6 +26,7 @@ public protocol DiscoveryRouter: BaseRouter { enrollmentStart: Date?, enrollmentEnd: Date?, title: String, + courseRawImage: String?, showDates: Bool, lastVisitedBlockID: String? ) @@ -59,6 +60,7 @@ public class DiscoveryRouterMock: BaseRouterMock, DiscoveryRouter { enrollmentStart: Date?, enrollmentEnd: Date?, title: String, + courseRawImage: String?, showDates: Bool, lastVisitedBlockID: String? ) {} diff --git a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift index 10c03ab40..092242d43 100644 --- a/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift +++ b/Discovery/Discovery/Presentation/NativeDiscovery/CourseDetailsView.swift @@ -292,6 +292,7 @@ private struct CourseStateView: View { enrollmentStart: courseDetails.enrollmentStart, enrollmentEnd: courseDetails.enrollmentEnd, title: title, + courseRawImage: courseDetails.courseRawImage, showDates: false, lastVisitedBlockID: nil ) diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift index df2330abf..8b9163dc1 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift @@ -228,6 +228,7 @@ extension DiscoveryWebviewViewModel: WebViewNavigationDelegate { enrollmentStart: courseDetails.enrollmentStart, enrollmentEnd: courseDetails.enrollmentEnd, title: courseDetails.courseTitle, + courseRawImage: courseDetails.courseRawImage, showDates: false, lastVisitedBlockID: nil ) diff --git a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift index 34ad47de2..ed636378b 100644 --- a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift +++ b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift @@ -226,6 +226,7 @@ extension ProgramWebviewViewModel: WebViewNavigationDelegate { enrollmentStart: courseDetails.enrollmentStart, enrollmentEnd: courseDetails.enrollmentEnd, title: courseDetails.courseTitle, + courseRawImage: courseDetails.courseRawImage, showDates: false, lastVisitedBlockID: nil ) diff --git a/Discovery/DiscoveryTests/Presentation/CourseDetailsViewModelTests.swift b/Discovery/DiscoveryTests/Presentation/CourseDetailsViewModelTests.swift index ba30a66ce..35d61d57e 100644 --- a/Discovery/DiscoveryTests/Presentation/CourseDetailsViewModelTests.swift +++ b/Discovery/DiscoveryTests/Presentation/CourseDetailsViewModelTests.swift @@ -44,7 +44,8 @@ final class CourseDetailsViewModelTests: XCTestCase { isEnrolled: true, overviewHTML: "", courseBannerURL: "", - courseVideoURL: nil + courseVideoURL: nil, + courseRawImage: nil ) @@ -90,7 +91,8 @@ final class CourseDetailsViewModelTests: XCTestCase { isEnrolled: true, overviewHTML: "", courseBannerURL: "", - courseVideoURL: nil + courseVideoURL: nil, + courseRawImage: nil ) Given(interactor, .getLoadedCourseDetails(courseID: "123", diff --git a/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift b/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift index 241178b03..b41d901be 100644 --- a/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift +++ b/Discovery/DiscoveryTests/Presentation/DiscoveryViewModelTests.swift @@ -46,6 +46,7 @@ final class DiscoveryViewModelTests: XCTestCase { courseID: "123", numPages: 2, coursesCount: 2, + courseRawImage: nil, progressEarned: 0, progressPossible: 0), CourseItem(name: "Test2", @@ -60,6 +61,7 @@ final class DiscoveryViewModelTests: XCTestCase { courseID: "1243", numPages: 1, coursesCount: 2, + courseRawImage: nil, progressEarned: 0, progressPossible: 0) ] @@ -99,6 +101,7 @@ final class DiscoveryViewModelTests: XCTestCase { courseID: "123", numPages: 2, coursesCount: 0, + courseRawImage: nil, progressEarned: 0, progressPossible: 0), CourseItem(name: "Test2", @@ -113,6 +116,7 @@ final class DiscoveryViewModelTests: XCTestCase { courseID: "1243", numPages: 1, coursesCount: 0, + courseRawImage: nil, progressEarned: 0, progressPossible: 0) ] @@ -151,6 +155,7 @@ final class DiscoveryViewModelTests: XCTestCase { courseID: "123", numPages: 2, coursesCount: 2, + courseRawImage: nil, progressEarned: 0, progressPossible: 0), CourseItem(name: "Test2", @@ -165,6 +170,7 @@ final class DiscoveryViewModelTests: XCTestCase { courseID: "1243", numPages: 1, coursesCount: 2, + courseRawImage: nil, progressEarned: 0, progressPossible: 0) ] diff --git a/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift b/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift index 3aa8e9394..aac3408c5 100644 --- a/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift +++ b/Discovery/DiscoveryTests/Presentation/SearchViewModelTests.swift @@ -49,6 +49,7 @@ final class SearchViewModelTests: XCTestCase { courseID: "123", numPages: 2, coursesCount: 0, + courseRawImage: nil, progressEarned: 0, progressPossible: 0), CourseItem(name: "Test2", @@ -63,6 +64,7 @@ final class SearchViewModelTests: XCTestCase { courseID: "1243", numPages: 1, coursesCount: 0, + courseRawImage: nil, progressEarned: 0, progressPossible: 0) ] diff --git a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift index ee44d90d1..61106f87a 100644 --- a/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift +++ b/Discussion/Discussion/Presentation/DiscussionTopics/DiscussionTopicsView.swift @@ -41,144 +41,146 @@ public struct DiscussionTopicsView: View { ZStack(alignment: .center) { VStack(alignment: .center) { ScrollView { - DynamicOffsetView( - coordinate: $coordinate, - collapsed: $collapsed, - viewHeight: $viewHeight - ) - RefreshProgressView(isShowRefresh: $viewModel.isShowRefresh) - // MARK: - Search fake field - if viewModel.isBlackedOut { - bannerDiscussionsDisabled - } - - if let topics = viewModel.discussionTopics, topics.count > 0 { - HStack(spacing: 11) { - Image(systemName: "magnifyingglass") - .foregroundColor(Theme.Colors.textInputTextColor) - .padding(.leading, 16) - .padding(.top, 1) - Text(DiscussionLocalization.Topics.search) - .foregroundColor(Theme.Colors.textInputTextColor) - .font(Theme.Fonts.bodyMedium) - Spacer() - } - .frame(minHeight: 48) - .background( - Theme.Shapes.textInputShape - .fill(Theme.Colors.textInputBackground) - ) - .overlay( - Theme.Shapes.textInputShape - .stroke(lineWidth: 1) - .fill(Theme.Colors.textInputUnfocusedStroke) + VStack(spacing: 0) { + DynamicOffsetView( + coordinate: $coordinate, + collapsed: $collapsed, + viewHeight: $viewHeight ) - .onTapGesture { - viewModel.router.showDiscussionsSearch( - courseID: courseID, - isBlackedOut: viewModel.isBlackedOut + RefreshProgressView(isShowRefresh: $viewModel.isShowRefresh) + // MARK: - Search fake field + if viewModel.isBlackedOut { + bannerDiscussionsDisabled + } + + if let topics = viewModel.discussionTopics, topics.count > 0 { + HStack(spacing: 11) { + Image(systemName: "magnifyingglass") + .foregroundColor(Theme.Colors.textInputTextColor) + .padding(.leading, 16) + .padding(.top, 1) + Text(DiscussionLocalization.Topics.search) + .foregroundColor(Theme.Colors.textInputTextColor) + .font(Theme.Fonts.bodyMedium) + Spacer() + } + .frame(minHeight: 48) + .background( + Theme.Shapes.textInputShape + .fill(Theme.Colors.textInputBackground) ) + .overlay( + Theme.Shapes.textInputShape + .stroke(lineWidth: 1) + .fill(Theme.Colors.textInputUnfocusedStroke) + ) + .onTapGesture { + viewModel.router.showDiscussionsSearch( + courseID: courseID, + isBlackedOut: viewModel.isBlackedOut + ) + } + .frameLimit(width: proxy.size.width) + .padding(.horizontal, 24) + .padding(.top, 10) + .accessibilityElement(children: .ignore) + .accessibilityLabel(DiscussionLocalization.Topics.search) } - .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) { - 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) + + // MARK: - Page Body + VStack { + ZStack(alignment: .top) { + 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) } } - } - } else if viewModel.isShowProgress == false { - FullScreenErrorView( - type: .noContent( - DiscussionLocalization.Error.unableToLoadDiscussion, - image: CoreAssets.information.swiftUIImage + } else if viewModel.isShowProgress == false { + FullScreenErrorView( + type: .noContent( + DiscussionLocalization.Error.unableToLoadDiscussion, + image: CoreAssets.information.swiftUIImage + ) ) - ) - .frame(maxWidth: .infinity) - .frame(height: proxy.size.height - viewHeight) - Spacer(minLength: -200) + .frame(maxWidth: .infinity) + .frame(height: proxy.size.height - viewHeight) + Spacer(minLength: -200) + } + Spacer(minLength: 200) } - Spacer(minLength: 200) + .frameLimit(width: proxy.size.width) } - .frameLimit(width: proxy.size.width) - } - .onRightSwipeGesture { - router.back() + .onRightSwipeGesture { + router.back() + } + } - } }.frame(maxWidth: .infinity) .refreshable { diff --git a/OpenEdX/Data/CoursePersistence.swift b/OpenEdX/Data/CoursePersistence.swift index eaca47bb5..4115de9f2 100644 --- a/OpenEdX/Data/CoursePersistence.swift +++ b/OpenEdX/Data/CoursePersistence.swift @@ -34,6 +34,7 @@ public class CoursePersistence: CoursePersistenceProtocol { courseID: $0.courseID ?? "", numPages: Int($0.numPages), coursesCount: Int($0.courseCount), + courseRawImage: $0.courseRawImage, progressEarned: 0, progressPossible: 0) } diff --git a/OpenEdX/Data/DashboardPersistence.swift b/OpenEdX/Data/DashboardPersistence.swift index 0a55aeaf7..9cac9921b 100644 --- a/OpenEdX/Data/DashboardPersistence.swift +++ b/OpenEdX/Data/DashboardPersistence.swift @@ -33,6 +33,7 @@ public class DashboardPersistence: DashboardPersistenceProtocol { courseID: $0.courseID ?? "", numPages: Int($0.numPages), coursesCount: Int($0.courseCount), + courseRawImage: $0.courseRawImage, progressEarned: 0, progressPossible: 0)} if let result, !result.isEmpty { @@ -59,6 +60,7 @@ public class DashboardPersistence: DashboardPersistenceProtocol { newItem.enrollmentEnd = item.enrollmentEnd newItem.numPages = Int32(item.numPages) newItem.courseID = item.courseID + newItem.courseRawImage = item.courseRawImage do { try context.save() @@ -131,6 +133,7 @@ public class DashboardPersistence: DashboardPersistenceProtocol { courseID: cdCourse.courseID ?? "", numPages: Int(cdCourse.numPages), coursesCount: Int(cdCourse.courseCount), + courseRawImage: cdCourse.courseRawImage, progressEarned: Int(cdCourse.progressEarned), progressPossible: Int(cdCourse.progressPossible) ) diff --git a/OpenEdX/Data/DiscoveryPersistence.swift b/OpenEdX/Data/DiscoveryPersistence.swift index b36f58fad..282b107ee 100644 --- a/OpenEdX/Data/DiscoveryPersistence.swift +++ b/OpenEdX/Data/DiscoveryPersistence.swift @@ -33,6 +33,7 @@ public class DiscoveryPersistence: DiscoveryPersistenceProtocol { courseID: $0.courseID ?? "", numPages: Int($0.numPages), coursesCount: Int($0.courseCount), + courseRawImage: $0.courseRawImage, progressEarned: 0, progressPossible: 0)} if let result, !result.isEmpty { @@ -59,6 +60,7 @@ public class DiscoveryPersistence: DiscoveryPersistenceProtocol { newItem.enrollmentEnd = item.enrollmentEnd newItem.numPages = Int32(item.numPages) newItem.courseID = item.courseID + newItem.courseRawImage = item.courseRawImage do { try context.save() @@ -86,7 +88,8 @@ public class DiscoveryPersistence: DiscoveryPersistenceProtocol { isEnrolled: courseDetails.isEnrolled, overviewHTML: courseDetails.overviewHTML ?? "", courseBannerURL: courseDetails.courseBannerURL ?? "", - courseVideoURL: nil + courseVideoURL: nil, + courseRawImage: courseDetails.courseRawImage ) } } @@ -105,6 +108,7 @@ public class DiscoveryPersistence: DiscoveryPersistenceProtocol { newCourseDetails.isEnrolled = course.isEnrolled newCourseDetails.overviewHTML = course.overviewHTML newCourseDetails.courseBannerURL = course.courseBannerURL + newCourseDetails.courseRawImage = course.courseRawImage do { try context.save() diff --git a/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift b/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift index 259cc2171..c77b2d4db 100644 --- a/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift +++ b/OpenEdX/Managers/DeepLinkManager/DeepLinkRouter/DeepLinkRouter.swift @@ -113,6 +113,7 @@ extension Router: DeepLinkRouter { enrollmentStart: courseDetails.enrollmentStart, enrollmentEnd: courseDetails.enrollmentEnd, title: courseDetails.courseTitle, + courseRawImage: courseDetails.courseRawImage, showDates: false, lastVisitedBlockID: nil ) diff --git a/OpenEdX/Managers/PipManager.swift b/OpenEdX/Managers/PipManager.swift index 94895077d..6938ae9a2 100644 --- a/OpenEdX/Managers/PipManager.swift +++ b/OpenEdX/Managers/PipManager.swift @@ -188,6 +188,7 @@ public class PipManager: PipManagerProtocol { enrollmentStart: courseDetails.enrollmentStart, enrollmentEnd: courseDetails.enrollmentEnd, title: courseDetails.courseTitle, + courseRawImage: courseDetails.courseRawImage, showDates: false, lastVisitedBlockID: nil ) diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 4302385d6..13f99ed02 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -365,6 +365,7 @@ public class Router: AuthorizationRouter, enrollmentStart: Date?, enrollmentEnd: Date?, title: String, + courseRawImage: String?, showDates: Bool, lastVisitedBlockID: String? ) { @@ -376,6 +377,7 @@ public class Router: AuthorizationRouter, enrollmentStart: enrollmentStart, enrollmentEnd: enrollmentEnd, title: title, + courseRawImage: courseRawImage, showDates: showDates, lastVisitedBlockID: lastVisitedBlockID ) @@ -394,6 +396,7 @@ public class Router: AuthorizationRouter, enrollmentStart: Date?, enrollmentEnd: Date?, title: String, + courseRawImage: String?, showDates: Bool, lastVisitedBlockID: String? ) -> UIHostingController { @@ -418,7 +421,8 @@ public class Router: AuthorizationRouter, viewModel: vm, courseDatesViewModel: datesVm, courseID: courseID, - title: title + title: title, + courseRawImage: courseRawImage ) return UIHostingController(rootView: screensView) From 255e2b77480bca225d868836f00c2bd216400d6d Mon Sep 17 00:00:00 2001 From: Volodymyr Chekyrta Date: Fri, 8 Nov 2024 12:51:09 +0200 Subject: [PATCH 55/55] build: update action versions (#541) --- .github/workflows/unit_tests.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/unit_tests.yml b/.github/workflows/unit_tests.yml index 99a84f445..8d72de40f 100644 --- a/.github/workflows/unit_tests.yml +++ b/.github/workflows/unit_tests.yml @@ -21,7 +21,7 @@ jobs: with: ref: ${{ github.event.pull_request.head.sha }} - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: vendor/bundle key: ${{ runner.os }}-gems-${{ hashFiles('**/Gemfile.lock') }} @@ -36,7 +36,7 @@ jobs: run: bundle exec fastlane unit_tests - name: Archive artifacts - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: always() with: name: test-output @@ -45,6 +45,6 @@ jobs: if-no-files-found: ignore - name: Upload coverage to Codecov - uses: codecov/codecov-action@v3 + uses: codecov/codecov-action@v4 with: flags: unittests