diff --git a/Core/Core.xcodeproj/project.pbxproj b/Core/Core.xcodeproj/project.pbxproj index e055bfc89..2b62eca0e 100644 --- a/Core/Core.xcodeproj/project.pbxproj +++ b/Core/Core.xcodeproj/project.pbxproj @@ -145,6 +145,8 @@ E055A53A2B18DC95008D9E5E /* Theme.framework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E055A5382B18DC95008D9E5E /* Theme.framework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; E09179FD2B0F204E002AB695 /* ConfigTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = E09179FC2B0F204D002AB695 /* ConfigTests.swift */; }; E0D586362B314CD3009B4BA7 /* LogistrationBottomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D586352B314CD3009B4BA7 /* LogistrationBottomView.swift */; }; + E0D5861A2B2FF74C009B4BA7 /* DiscoveryConfig.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D586192B2FF74C009B4BA7 /* DiscoveryConfig.swift */; }; + E0D5861C2B2FF85B009B4BA7 /* RawStringExtactable.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D5861B2B2FF85B009B4BA7 /* RawStringExtactable.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -316,6 +318,8 @@ 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 = ""; }; E0D586352B314CD3009B4BA7 /* LogistrationBottomView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LogistrationBottomView.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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -430,6 +434,7 @@ BA8B3A2E2AD546A700D25EF5 /* DebugLog.swift */, BADB3F5A2AD6EC56004D5CFA /* ResultExtension.swift */, 02D400602B0678190029D168 /* SKStoreReviewControllerExtension.swift */, + E0D5861B2B2FF85B009B4BA7 /* RawStringExtactable.swift */, ); path = Extensions; sourceTree = ""; @@ -710,6 +715,7 @@ BAFB998F2B14B377007D09F9 /* GoogleConfig.swift */, BAFB99912B14E23D007D09F9 /* AppleSignInConfig.swift */, A53A32342B233DEC005FE38A /* ThemeConfig.swift */, + E0D586192B2FF74C009B4BA7 /* DiscoveryConfig.swift */, ); path = Config; sourceTree = ""; @@ -996,6 +1002,7 @@ BA30427F2B20B320009B64B7 /* SocialAuthError.swift in Sources */, 0284DBFE28D48C5300830893 /* CourseItem.swift in Sources */, 0248C92329C075EF00DC8402 /* CourseBlockModel.swift in Sources */, + E0D5861A2B2FF74C009B4BA7 /* DiscoveryConfig.swift in Sources */, DBF6F2412B014ADA0098414B /* FirebaseConfig.swift in Sources */, 072787B628D37A0E002E9142 /* Validator.swift in Sources */, 0236961D28F9A2D200EEF206 /* Data_AuthResponse.swift in Sources */, @@ -1003,6 +1010,8 @@ 0233D5712AF13EC800BAC8BD /* SelectMailClientView.swift in Sources */, BAFB99842B0E282E007D09F9 /* MicrosoftConfig.swift in Sources */, 02B2B594295C5C7A00914876 /* Thread.swift in Sources */, + 027DB33528D8C8FE002B6862 /* Data_MyCourse.swift in Sources */, + E0D5861C2B2FF85B009B4BA7 /* RawStringExtactable.swift in Sources */, 027BD3BD2909478B00392132 /* UIView+EnclosingScrollView.swift in Sources */, BA8FA6682AD59A5700EA029A /* SocialAuthButton.swift in Sources */, 02D400612B0678190029D168 /* SKStoreReviewControllerExtension.swift in Sources */, diff --git a/Core/Core/Configuration/Config/Config.swift b/Core/Core/Configuration/Config/Config.swift index 7cd10c224..7fcbe1c94 100644 --- a/Core/Core/Configuration/Config/Config.swift +++ b/Core/Core/Configuration/Config/Config.swift @@ -24,6 +24,7 @@ public protocol ConfigProtocol { var features: FeaturesConfig { get } var theme: ThemeConfig { get } var uiComponents: UIComponentsConfig { get } + var discovery: DiscoveryConfig { get } } public enum TokenType: String { diff --git a/Core/Core/Configuration/Config/DiscoveryConfig.swift b/Core/Core/Configuration/Config/DiscoveryConfig.swift new file mode 100644 index 000000000..592ba09ee --- /dev/null +++ b/Core/Core/Configuration/Config/DiscoveryConfig.swift @@ -0,0 +1,57 @@ +// +// DiscoveryConfig.swift +// Core +// +// Created by SaeedBashir on 12/18/23. +// + +import Foundation + +public enum DiscoveryConfigType: String { + case native + case webview + case none +} + +private enum DiscoveryKeys: String, RawStringExtractable { + case discoveryType = "TYPE" + case webview = "WEBVIEW" + case baseURL = "BASE_URL" + case courseDetailTemplate = "COURSE_DETAIL_TEMPLATE" + case programDetailTemplate = "PROGRAM_DETAIL_TEMPLATE" +} + +public class DiscoveryWebviewConfig: NSObject { + public let baseURL: String? + public let courseDetailTemplate: String? + public let programDetailTemplate: String? + + init(dictionary: [String: AnyObject]) { + baseURL = dictionary[DiscoveryKeys.baseURL] as? String + courseDetailTemplate = dictionary[DiscoveryKeys.courseDetailTemplate] as? String + programDetailTemplate = dictionary[DiscoveryKeys.programDetailTemplate] as? String + } +} + +public class DiscoveryConfig: NSObject { + public let type: DiscoveryConfigType + public let webview: DiscoveryWebviewConfig + + init(dictionary: [String: AnyObject]) { + type = (dictionary[DiscoveryKeys.discoveryType] as? String).flatMap { + DiscoveryConfigType(rawValue: $0) + } ?? .none + webview = DiscoveryWebviewConfig(dictionary: dictionary[DiscoveryKeys.webview] as? [String: AnyObject] ?? [:]) + } + + var isEnabled: Bool { + return type != .none + } +} + +private let key = "DISCOVERY" +extension Config { + public var discovery: DiscoveryConfig { + DiscoveryConfig(dictionary: self[key] as? [String: AnyObject] ?? [:]) + } +} diff --git a/Core/Core/Extensions/RawStringExtactable.swift b/Core/Core/Extensions/RawStringExtactable.swift new file mode 100644 index 000000000..8ab42f2ed --- /dev/null +++ b/Core/Core/Extensions/RawStringExtactable.swift @@ -0,0 +1,27 @@ +// +// 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/SwiftGen/Strings.swift b/Core/Core/SwiftGen/Strings.swift index 5fb863c94..71a71130f 100644 --- a/Core/Core/SwiftGen/Strings.swift +++ b/Core/Core/SwiftGen/Strings.swift @@ -193,6 +193,8 @@ public enum CoreLocalization { public enum Alert { /// Cancel public static let cancel = CoreLocalization.tr("Localizable", "WEBVIEW.ALERT.CANCEL", fallback: "Cancel") + /// Continue + public static let `continue` = CoreLocalization.tr("Localizable", "WEBVIEW.ALERT.CONTINUE", fallback: "Continue") /// Ok public static let ok = CoreLocalization.tr("Localizable", "WEBVIEW.ALERT.OK", fallback: "Ok") } diff --git a/Core/Core/View/Base/WebView.swift b/Core/Core/View/Base/WebView.swift index 917fa9c7b..b200e977e 100644 --- a/Core/Core/View/Base/WebView.swift +++ b/Core/Core/View/Base/WebView.swift @@ -10,6 +10,10 @@ import WebKit import SwiftUI import Theme +public protocol WebViewNavigationDelegate: AnyObject { + func webView(_ webView: WKWebView, shouldLoad request: URLRequest, navigationAction: WKNavigationAction) -> Bool +} + public struct WebView: UIViewRepresentable { public class ViewModel: ObservableObject { @@ -25,12 +29,20 @@ public struct WebView: UIViewRepresentable { @ObservedObject var viewModel: ViewModel @Binding public var isLoading: Bool + weak var webViewNavDelegate: WebViewNavigationDelegate? + var refreshCookies: () async -> Void - public init(viewModel: ViewModel, isLoading: Binding, refreshCookies: @escaping () async -> Void) { + public init( + viewModel: ViewModel, + isLoading: Binding, + refreshCookies: @escaping () async -> Void, + navigationDelegate: WebViewNavigationDelegate? = nil + ) { self.viewModel = viewModel self._isLoading = isLoading self.refreshCookies = refreshCookies + self.webViewNavDelegate = navigationDelegate } public class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate { @@ -79,6 +91,16 @@ public struct WebView: UIViewRepresentable { guard let url = navigationAction.request.url else { return .cancel } + let isWebViewDelegateHandled = await ( + parent.webViewNavDelegate?.webView( + webView, + shouldLoad: navigationAction.request, navigationAction: navigationAction) ?? false + ) + + if isWebViewDelegateHandled { + return .cancel + } + let baseURL = await parent.viewModel.baseURL if !baseURL.isEmpty, !url.absoluteString.starts(with: baseURL) { if navigationAction.navigationType == .other { @@ -139,8 +161,8 @@ public struct WebView: UIViewRepresentable { webView.backgroundColor = .clear webView.scrollView.backgroundColor = Theme.Colors.white.uiColor() webView.scrollView.alwaysBounceVertical = false - webView.scrollView.layer.cornerRadius = 24 - webView.scrollView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] +// webView.scrollView.layer.cornerRadius = 24 +// webView.scrollView.layer.maskedCorners = [.layerMinXMinYCorner, .layerMaxXMinYCorner] webView.scrollView.contentInset = UIEdgeInsets(top: 0, left: 0, bottom: 200, right: 0) return webView diff --git a/Core/Core/en.lproj/Localizable.strings b/Core/Core/en.lproj/Localizable.strings index 01ef3f850..673144473 100644 --- a/Core/Core/en.lproj/Localizable.strings +++ b/Core/Core/en.lproj/Localizable.strings @@ -70,6 +70,7 @@ "WEBVIEW.ALERT.OK" = "Ok"; "WEBVIEW.ALERT.CANCEL" = "Cancel"; +"WEBVIEW.ALERT.CONTINUE" = "Continue"; "REVIEW.VOTE_TITLE" = "Enjoying Open edX?"; "REVIEW.VOTE_DESCRIPTION" = "Your feedback matters to us. Would you take a moment to rate the app by tapping a star below? Thanks for your support!"; diff --git a/Core/Core/uk.lproj/Localizable.strings b/Core/Core/uk.lproj/Localizable.strings index d2128641b..ab592e519 100644 --- a/Core/Core/uk.lproj/Localizable.strings +++ b/Core/Core/uk.lproj/Localizable.strings @@ -69,6 +69,7 @@ "WEBVIEW.ALERT.OK" = "Так"; "WEBVIEW.ALERT.CANCEL" = "Скасувати"; +"WEBVIEW.ALERT.CONTINUE" = "Continue"; "REVIEW.VOTE_TITLE" = "Вам подобається Open edX?"; diff --git a/Discovery/Discovery.xcodeproj/project.pbxproj b/Discovery/Discovery.xcodeproj/project.pbxproj index 6b3a59362..2f841cfad 100644 --- a/Discovery/Discovery.xcodeproj/project.pbxproj +++ b/Discovery/Discovery.xcodeproj/project.pbxproj @@ -32,6 +32,11 @@ CFC849452996A52A0055E497 /* SearchViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFC849442996A52A0055E497 /* SearchViewModel.swift */; }; CFC8494C299A66080055E497 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = CFC8494E299A66080055E497 /* Localizable.stringsdict */; }; CFC84950299BE52C0055E497 /* SearchViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CFC8494F299BE52C0055E497 /* SearchViewModelTests.swift */; }; + E0D586202B300095009B4BA7 /* DiscoverWebviewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D5861E2B300095009B4BA7 /* DiscoverWebviewModel.swift */; }; + E0D586212B300095009B4BA7 /* DiscoveryWebview.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D5861F2B300095009B4BA7 /* DiscoveryWebview.swift */; }; + E0D586232B3000AD009B4BA7 /* DiscoveryHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D586222B3000AD009B4BA7 /* DiscoveryHelper.swift */; }; + E0D586252B300134009B4BA7 /* URL+PathExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D586242B300134009B4BA7 /* URL+PathExtension.swift */; }; + E0D586272B3004BA009B4BA7 /* DiscoveryCourseDetailWebview.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0D586262B3004BA009B4BA7 /* DiscoveryCourseDetailWebview.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -87,6 +92,12 @@ CFC8494D299A66080055E497 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = en; path = en.lproj/Localizable.stringsdict; sourceTree = ""; }; CFC8494F299BE52C0055E497 /* SearchViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchViewModelTests.swift; sourceTree = ""; }; E0D586132B29F25A009B4BA7 /* Authorization.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Authorization.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + E0D5861E2B300095009B4BA7 /* DiscoverWebviewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DiscoverWebviewModel.swift; sourceTree = ""; }; + E0D5861F2B300095009B4BA7 /* DiscoveryWebview.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DiscoveryWebview.swift; sourceTree = ""; }; + E0D586222B3000AD009B4BA7 /* DiscoveryHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryHelper.swift; sourceTree = ""; }; + E0D586242B300134009B4BA7 /* URL+PathExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "URL+PathExtension.swift"; sourceTree = ""; }; + E0D586262B3004BA009B4BA7 /* DiscoveryCourseDetailWebview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiscoveryCourseDetailWebview.swift; sourceTree = ""; }; + E0D586282B302C3A009B4BA7 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; E192F9B4A7EECED9665AB8A7 /* Pods-App-Discovery.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Discovery.releasedev.xcconfig"; path = "Target Support Files/Pods-App-Discovery/Pods-App-Discovery.releasedev.xcconfig"; sourceTree = ""; }; F340BD73D38B0DF9E4EA6482 /* Pods-App-Discovery-DiscoveryUnitTests.releaseprod.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Discovery-DiscoveryUnitTests.releaseprod.xcconfig"; path = "Target Support Files/Pods-App-Discovery-DiscoveryUnitTests/Pods-App-Discovery-DiscoveryUnitTests.releaseprod.xcconfig"; sourceTree = ""; }; FF565519B9BBC73E92249648 /* Pods-App-Discovery-DiscoveryUnitTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Discovery-DiscoveryUnitTests.release.xcconfig"; path = "Target Support Files/Pods-App-Discovery-DiscoveryUnitTests/Pods-App-Discovery-DiscoveryUnitTests.release.xcconfig"; sourceTree = ""; }; @@ -179,6 +190,7 @@ 070019A228F6EF2700D5FC78 /* Presentation */ = { isa = PBXGroup; children = ( + E0D5861D2B300095009B4BA7 /* WebDiscovery */, 029242E52AE6976E00A940EC /* UpdateViews */, 072787B328D34D91002E9142 /* DiscoveryView.swift */, 0283347828D49A8700C828FC /* DiscoveryViewModel.swift */, @@ -214,6 +226,7 @@ 0727879B28D34C03002E9142 /* Discovery */ = { isa = PBXGroup; children = ( + E0D586282B302C3A009B4BA7 /* Info.plist */, 02EF39CB28D866C50058F6BD /* SwiftGen */, 0284DBF828D4831000830893 /* Data */, 0284DC0428D4996F00830893 /* Domain */, @@ -268,6 +281,18 @@ path = ../Pods; sourceTree = ""; }; + E0D5861D2B300095009B4BA7 /* WebDiscovery */ = { + isa = PBXGroup; + children = ( + E0D5861E2B300095009B4BA7 /* DiscoverWebviewModel.swift */, + E0D5861F2B300095009B4BA7 /* DiscoveryWebview.swift */, + E0D586222B3000AD009B4BA7 /* DiscoveryHelper.swift */, + E0D586242B300134009B4BA7 /* URL+PathExtension.swift */, + E0D586262B3004BA009B4BA7 /* DiscoveryCourseDetailWebview.swift */, + ); + path = WebDiscovery; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXHeadersBuildPhase section */ @@ -483,15 +508,20 @@ 0284DBFC28D4856A00830893 /* DiscoveryEndpoint.swift in Sources */, 029737402949FB070051696B /* DiscoveryCoreModel.xcdatamodeld in Sources */, 029242E72AE6978400A940EC /* UpdateRequiredView.swift in Sources */, + E0D586232B3000AD009B4BA7 /* DiscoveryHelper.swift in Sources */, 0283347728D499BC00C828FC /* DiscoveryInteractor.swift in Sources */, 02F3BFDF29252F2F0051930C /* DiscoveryRouter.swift in Sources */, 0283347928D49A8700C828FC /* DiscoveryViewModel.swift in Sources */, 072787B428D34D91002E9142 /* DiscoveryView.swift in Sources */, + E0D586252B300134009B4BA7 /* URL+PathExtension.swift in Sources */, 029737422949FB3B0051696B /* DiscoveryPersistenceProtocol.swift in Sources */, 0284DC0328D4922900830893 /* DiscoveryRepository.swift in Sources */, 029242EB2AE6AB7B00A940EC /* UpdateNotificationView.swift in Sources */, 02EF39DC28D86BEF0058F6BD /* Strings.swift in Sources */, + E0D586202B300095009B4BA7 /* DiscoverWebviewModel.swift in Sources */, 02F1752F2A4DA3B60019CD70 /* DiscoveryAnalytics.swift in Sources */, + E0D586272B3004BA009B4BA7 /* DiscoveryCourseDetailWebview.swift in Sources */, + E0D586212B300095009B4BA7 /* DiscoveryWebview.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -731,6 +761,7 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -845,6 +876,7 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -1022,6 +1054,7 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -1057,6 +1090,7 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -1155,6 +1189,7 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -1254,6 +1289,7 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -1347,6 +1383,7 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; @@ -1439,6 +1476,7 @@ DYLIB_CURRENT_VERSION = 1; DYLIB_INSTALL_NAME_BASE = "@rpath"; GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Discovery/Info.plist; INFOPLIST_KEY_NSHumanReadableCopyright = ""; INSTALL_PATH = "$(LOCAL_LIBRARY_DIR)/Frameworks"; IPHONEOS_DEPLOYMENT_TARGET = 15.0; diff --git a/Discovery/Discovery/Presentation/DiscoveryRouter.swift b/Discovery/Discovery/Presentation/DiscoveryRouter.swift index 32b13a6e4..c4a87d03d 100644 --- a/Discovery/Discovery/Presentation/DiscoveryRouter.swift +++ b/Discovery/Discovery/Presentation/DiscoveryRouter.swift @@ -9,8 +9,8 @@ import Foundation import Core public protocol DiscoveryRouter: BaseRouter { - func showCourseDetais(courseID: String, title: String) + func showWebDiscoveryDetails(pathID: String, discoveryType: DiscoveryWebviewType) func showUpdateRequiredView(showAccountLink: Bool) func showUpdateRecomendedView() func showDiscoverySearch(searchQuery: String?) @@ -23,6 +23,7 @@ public class DiscoveryRouterMock: BaseRouterMock, DiscoveryRouter { public override init() {} public func showCourseDetais(courseID: String, title: String) {} + public func showWebDiscoveryDetails(pathID: String, discoveryType: DiscoveryWebviewType) {} public func showUpdateRequiredView(showAccountLink: Bool) {} public func showUpdateRecomendedView() {} public func showDiscoverySearch(searchQuery: String? = nil) {} diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoverWebviewModel.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoverWebviewModel.swift new file mode 100644 index 000000000..ca96b5225 --- /dev/null +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoverWebviewModel.swift @@ -0,0 +1,83 @@ +// +// DiscoverWebviewModel.swift +// Discovery +// +// Created by SaeedBashir on 12/16/23. +// + +import Foundation +import Core +import WebKit + +public class DiscoveryWebviewModel: ObservableObject { + let router: DiscoveryRouter + let config: ConfigProtocol + let connectivity: ConnectivityProtocol + private let interactor: DiscoveryInteractorProtocol + private let analytics: DiscoveryAnalytics + var request: URLRequest? = nil + + public init( + router: DiscoveryRouter, + config: ConfigProtocol, + interactor: DiscoveryInteractorProtocol, + connectivity: ConnectivityProtocol, + analytics: DiscoveryAnalytics + ) { + self.router = router + self.config = config + self.interactor = interactor + self.connectivity = connectivity + self.analytics = analytics + } +} + +extension DiscoveryWebviewModel: WebViewNavigationDelegate { + public func webView( + _ webView: WKWebView, + shouldLoad request: URLRequest, + navigationAction: WKNavigationAction + ) -> Bool { + guard let URL = request.url else { return false } + + let navigationHandled = DiscoveryHelper.navigate(to: URL, router: router) + if navigationHandled { return true } + + let capturedLink = navigationAction.navigationType == .linkActivated + let outsideLink = (request.mainDocumentURL?.host != self.request?.url?.host) + var externalLink = false + + if let queryParameters = request.url?.queryParameters, + let externalLinkValue = queryParameters["external_link"] as? String, + externalLinkValue.caseInsensitiveCompare("true") == .orderedSame { + externalLink = true + } + + if let url = request.url, outsideLink || capturedLink || externalLink, UIApplication.shared.canOpenURL(url) { + let alertController = UIAlertController( + title: "Leaving the app", + message: "You are now leaving the app and opening a browser.", preferredStyle: .alert + ) + + alertController.addAction(UIAlertAction( + title: CoreLocalization.Webview.Alert.continue, + style: .default, + handler: { _ in + UIApplication.shared.open(url, options: [:]) + })) + + alertController.addAction(UIAlertAction( + title: CoreLocalization.Webview.Alert.cancel, + style: .cancel, + handler: { _ in + + })) + DispatchQueue.main.async { + UIApplication.topViewController()?.present(alertController, animated: true, completion: nil) + } + return true + } + + return false + } +} diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryCourseDetailWebview.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryCourseDetailWebview.swift new file mode 100644 index 000000000..6fb1053bf --- /dev/null +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryCourseDetailWebview.swift @@ -0,0 +1,54 @@ +// +// DiscoveryCourseDetailWebview.swift +// Discovery +// +// Created by SaeedBashir on 12/18/23. +// + +import Foundation +import SwiftUI +import Core +import Theme + +public struct DiscoveryCourseDetailWebview: View { + @StateObject private var viewModel: DiscoveryWebviewModel + + @State private var searchQuery: String = "" + @State private var isLoading: Bool = true + + private var router: DiscoveryRouter + private var fromStartupScreen: Bool = false + + public init( + viewModel: DiscoveryWebviewModel, + router: DiscoveryRouter, + searchQuery: String? = nil, + fromStartupScreen: Bool = false + ) { + self._viewModel = StateObject(wrappedValue: { viewModel }()) + self.router = router + self.fromStartupScreen = fromStartupScreen + self._searchQuery = State(initialValue: searchQuery ?? "") + } + + public var body: some View { + ZStack(alignment: .top) { + VStack(alignment: .center) { + GeometryReader { _ in + WebView( + viewModel: .init( + url: viewModel.config.discovery.webview.baseURL ?? "", + baseURL: "" + ), + isLoading: $isLoading, + refreshCookies: {}, + navigationDelegate: viewModel + ) + } + } + } + .navigationBarHidden(fromStartupScreen ? false : true) + .navigationTitle(fromStartupScreen ? "Explore the catalog" : "") + .background(Theme.Colors.background.ignoresSafeArea()) + } +} diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryHelper.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryHelper.swift new file mode 100644 index 000000000..5a6f0b4df --- /dev/null +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryHelper.swift @@ -0,0 +1,109 @@ +// +// DiscoveryHelper.swift +// Discovery +// +// Created by SaeedBashir on 12/18/23. +// + +import Foundation +import Core + +public enum URIString: String { + case appURLScheme = "edxapp" + case pathPlaceHolder = "{path_id}" +} + +private enum URLParameterKeys: String, RawStringExtractable { + case pathId = "path_id" + case courseId = "course_id" + case emailOptIn = "email_opt_in" +} + +// Define Your Hosts Here +enum WebviewActions: String { + case courseEnrollment = "enroll" + case courseDetail = "course_info" + case enrolledCourseDetail = "enrolled_course_info" + case enrolledProgramDetail = "enrolled_program_info" + case programDetail = "program_info" + case courseProgram = "course" +} + +public class DiscoveryHelper: NSObject { + + class func urlAction(from url: URL) -> WebviewActions? { + guard url.isValidAppURLScheme, let url = WebviewActions(rawValue: url.appURLHost) else { + return nil + } + return url + } + + class func detailPathID(from url: URL) -> String? { + guard url.isValidAppURLScheme, url.appURLHost == WebviewActions.courseDetail.rawValue, let path = url.queryParameters?[URLParameterKeys.pathId] as? String else { + return nil + } + + return path + } + + class func parse(url: URL) -> (courseId: String?, emailOptIn: Bool)? { + guard url.isValidAppURLScheme else { + return nil + } + let courseId = url.queryParameters?[URLParameterKeys.courseId] as? String + let emailOptIn = (url.queryParameters?[URLParameterKeys.emailOptIn] as? String).flatMap {Bool($0)} + + return (courseId , emailOptIn ?? false) + } + + + // class func programDetailURL(from url: URL, config: ConfigProtocol) -> URL? { + // guard url.isValidAppURLScheme, let path = url.queryParameters?[URLParameterKeys.pathId] as? String, let myProgramDetailURL = config.programConfig.programDetailURLTemplate else { + // return nil + // } + // let programDetailUrlString = myProgramDetailURL.replacingOccurrences(of: URIString.pathPlaceHolder.rawValue, with: path) + // return URL(string: programDetailUrlString) + // } + + class func programDetailPathId(from url: URL) -> String? { + guard url.isValidAppURLScheme, + url.appURLHost == WebviewActions.programDetail.rawValue, + let path = url.queryParameters?[URLParameterKeys.pathId] as? String else { + return nil + } + return path + } + +} + +public extension DiscoveryHelper { + class func navigate(to url: URL, router: DiscoveryRouter) -> Bool { + guard let urlAction = urlAction(from: url) else { return false } + switch urlAction { + case .courseEnrollment: + // enrollInCourse(with: url, from: controller) + break + case .courseDetail: + guard let pathID = detailPathID(from: url) else { return false } + router.showWebDiscoveryDetails(pathID: pathID, discoveryType: .courseDetail) + case .enrolledCourseDetail: + guard let urlData = parse(url: url), let courseId = urlData.courseId else { return false } + // environment.router?.showCourseWithID(courseID: courseId, fromController: controller, animated: true) + break + case .enrolledProgramDetail: + // guard let programDetailsURL = programDetailURL(from: url, config: environment.config) else { return false } + // environment.router?.showProgramDetails(with: programDetailsURL, from: controller) + break + case .programDetail: + guard let pathID = programDetailPathId(from: url) else { return false } + router.showWebDiscoveryDetails(pathID: pathID, discoveryType: .programDetail) + + case .courseProgram: + // environment.router?.showDiscoveryController(from: controller, type: .discovery, isUserLoggedIn: true, pathID: nil) + break + } + + return true + } +} + diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift new file mode 100644 index 000000000..98907e438 --- /dev/null +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebview.swift @@ -0,0 +1,91 @@ +// +// DiscoveryWebview.swift +// Discovery +// +// Created by SaeedBashir on 12/16/23. +// + +import Foundation +import SwiftUI +import Theme +import Core + + +public enum DiscoveryWebviewType { + case discovery + case courseDetail + case programDetail +} + +public struct DiscoveryWebview: View { + @StateObject private var viewModel: DiscoveryWebviewModel + + @State private var searchQuery: String = "" + @State private var isLoading: Bool = true + + private var router: DiscoveryRouter + private var fromStartupScreen: Bool = false + private var discoveryType: DiscoveryWebviewType + private var pathID: String + + private var URLString: String { + switch discoveryType { + case .discovery: + return viewModel.config.discovery.webview.baseURL ?? "" + case .courseDetail: + let template = viewModel.config.discovery.webview.courseDetailTemplate + return template?.replacingOccurrences( + of: URIString.pathPlaceHolder.rawValue, + with: pathID + ) ?? "" + + case .programDetail: + let template = viewModel.config.discovery.webview.programDetailTemplate + return template?.replacingOccurrences( + of: URIString.pathPlaceHolder.rawValue, + with: pathID + ) ?? "" + } + } + + public init( + viewModel: DiscoveryWebviewModel, + router: DiscoveryRouter, + searchQuery: String? = nil, + fromStartupScreen: Bool = false, + discoveryType: DiscoveryWebviewType = .discovery, + pathID: String = "" + ) { + self._viewModel = StateObject(wrappedValue: { viewModel }()) + self.router = router + self.fromStartupScreen = fromStartupScreen + 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 { + ZStack(alignment: .top) { + VStack(alignment: .center) { + GeometryReader { _ in + WebView( + viewModel: .init( + url: URLString, + baseURL: "" + ), + isLoading: $isLoading, + refreshCookies: {}, + navigationDelegate: viewModel + ) + } + } + } + .navigationBarHidden((discoveryType != .discovery || fromStartupScreen) ? false : true) + .navigationTitle(CoreLocalization.Mainscreen.discovery) + .background(Theme.Colors.background.ignoresSafeArea()) + } +} diff --git a/Discovery/Discovery/Presentation/WebDiscovery/URL+PathExtension.swift b/Discovery/Discovery/Presentation/WebDiscovery/URL+PathExtension.swift new file mode 100644 index 000000000..ca89f7d77 --- /dev/null +++ b/Discovery/Discovery/Presentation/WebDiscovery/URL+PathExtension.swift @@ -0,0 +1,36 @@ +// +// URL+PathExtension.swift +// Discovery +// +// Created by SaeedBashir on 12/18/23. +// + +import Foundation + +public extension URL { + var appURLHost: String { + return host ?? "" + } + + var isValidAppURLScheme: Bool { + return scheme ?? "" == URIString.appURLScheme.rawValue ? true : false + } + + var queryParameters: [String: Any]? { + guard let queryString = query else { + return nil + } + var queryParameters = [String: Any]() + let parameters = queryString.components(separatedBy: "&") + for parameter in parameters { + let keyValuePair = parameter.components(separatedBy: "=") + // Parameter will be ignored if invalid data for keyValuePair + if keyValuePair.count == 2 { + let key = keyValuePair[0] + let value = keyValuePair[1] + queryParameters[key] = value + } + } + return queryParameters + } +} diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 710764940..e10ac351e 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -109,6 +109,16 @@ class ScreenAssembly: Assembly { ) } + container.register(DiscoveryWebviewModel.self) { r in + DiscoveryWebviewModel( + router: r.resolve(DiscoveryRouter.self)!, + config: r.resolve(ConfigProtocol.self)!, + interactor: r.resolve(DiscoveryInteractorProtocol.self)!, + connectivity: r.resolve(ConnectivityProtocol.self)!, + analytics: r.resolve(DiscoveryAnalytics.self)! + ) + } + container.register(SearchViewModel.self) { r in SearchViewModel( interactor: r.resolve(DiscoveryInteractorProtocol.self)!, diff --git a/OpenEdX/Info.plist b/OpenEdX/Info.plist index e4bdcba95..b74f3967c 100644 --- a/OpenEdX/Info.plist +++ b/OpenEdX/Info.plist @@ -20,6 +20,11 @@ fastmail protonmail + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + UIAppFonts UIViewControllerBasedStatusBarAppearance diff --git a/OpenEdX/Router.swift b/OpenEdX/Router.swift index 5e69b64ad..84933140e 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -210,6 +210,21 @@ public class Router: AuthorizationRouter, navigationController.pushViewController(controller, animated: true) } + public func showWebDiscoveryDetails(pathID: String, discoveryType: DiscoveryWebviewType) { + let config = Container.shared.resolve(ConfigProtocol.self) + let view = DiscoveryWebview( + viewModel: Container.shared.resolve(DiscoveryWebviewModel.self)!, + router: Container.shared.resolve(DiscoveryRouter.self)!, + discoveryType: discoveryType, + pathID: pathID + ) + + DispatchQueue.main.async { [weak self] in + let controller = UIHostingController(rootView: view) + self?.navigationController.pushViewController(controller, animated: true) + } + } + public func showDiscoverySearch(searchQuery: String? = nil) { let viewModel = Container.shared.resolve(SearchViewModel.self)! let view = SearchView(viewModel: viewModel, searchQuery: searchQuery) @@ -219,14 +234,27 @@ public class Router: AuthorizationRouter, } public func showDiscoveryScreen(searchQuery: String? = nil, sourceScreen: LogistrationSourceScreen) { - let view = DiscoveryView( - viewModel: Container.shared.resolve(DiscoveryViewModel.self)!, - router: Container.shared.resolve(DiscoveryRouter.self)!, - searchQuery: searchQuery, - sourceScreen: sourceScreen - ) - let controller = UIHostingController(rootView: view) - navigationController.pushViewController(controller, animated: true) + let config = Container.shared.resolve(ConfigProtocol.self) + if config?.discovery.type == .native { + let view = DiscoveryView( + viewModel: Container.shared.resolve(DiscoveryViewModel.self)!, + router: Container.shared.resolve(DiscoveryRouter.self)!, + searchQuery: searchQuery, + sourceScreen: sourceScreen + ) + + let controller = UIHostingController(rootView: view) + navigationController.pushViewController(controller, animated: true) + } else if config?.discovery.type == .webview { + let view = DiscoveryWebview( + viewModel: Container.shared.resolve(DiscoveryWebviewModel.self)!, + router: Container.shared.resolve(DiscoveryRouter.self)!, + searchQuery: searchQuery + ) + + let controller = UIHostingController(rootView: view) + navigationController.pushViewController(controller, animated: true) + } } public func showDiscussionsSearch(courseID: String) { diff --git a/OpenEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift index 6f26a929e..a987e75f6 100644 --- a/OpenEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -42,11 +42,19 @@ struct MainScreenView: View { var body: some View { TabView(selection: $selection) { ZStack { - DiscoveryView( - viewModel: Container.shared.resolve(DiscoveryViewModel.self)!, - router: Container.shared.resolve(DiscoveryRouter.self)!, - sourceScreen: viewModel.sourceScreen - ) + let config = Container.shared.resolve(ConfigProtocol.self) + if config?.discovery.type == .native { + DiscoveryView( + viewModel: Container.shared.resolve(DiscoveryViewModel.self)!, + router: Container.shared.resolve(DiscoveryRouter.self)! + ) + } else if config?.discovery.type == .webview { + DiscoveryWebview( + viewModel: Container.shared.resolve(DiscoveryWebviewModel.self)!, + router: Container.shared.resolve(DiscoveryRouter.self)! + ) + } + if updateAvaliable { UpdateNotificationView(config: viewModel.config) }