From a03bb3ed4f415fca1c604e1ab746342e27a3718c Mon Sep 17 00:00:00 2001 From: Saeed Bashir Date: Fri, 2 Feb 2024 20:16:22 +0500 Subject: [PATCH] feat: Enrolled Web Based Programs Implementation (#260) * feat: enrolled programs feature implimentation * refactor: config refactoring * refactor: using configureable URIScheme * refactor: address review feedback * refactor: address review feedback * fix: fix failure after merge * refactor: remove extra space --- Core/Core/Configuration/Config/Config.swift | 9 +- .../Config/DiscoveryConfig.swift | 7 + Core/Core/View/Base/Webview/WebView.swift | 6 +- Discovery/Discovery.xcodeproj/project.pbxproj | 16 ++ .../Presentation/DiscoveryRouter.swift | 10 + .../WebDiscovery/DiscoveryURIDetails.swift | 1 - .../DiscoveryWebviewViewModel.swift | 46 ++-- .../WebDiscovery/URL+PathExtension.swift | 4 - .../WebPrograms/ProgramWebviewView.swift | 108 ++++++++ .../WebPrograms/ProgramWebviewViewModel.swift | 232 ++++++++++++++++++ OpenEdX/DI/ScreenAssembly.swift | 10 + OpenEdX/Router.swift | 14 ++ OpenEdX/View/MainScreenView.swift | 28 ++- 13 files changed, 453 insertions(+), 38 deletions(-) create mode 100644 Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift create mode 100644 Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift diff --git a/Core/Core/Configuration/Config/Config.swift b/Core/Core/Configuration/Config/Config.swift index 7fcbe1c9..481f67b9 100644 --- a/Core/Core/Configuration/Config/Config.swift +++ b/Core/Core/Configuration/Config/Config.swift @@ -25,6 +25,8 @@ public protocol ConfigProtocol { var theme: ThemeConfig { get } var uiComponents: UIComponentsConfig { get } var discovery: DiscoveryConfig { get } + var program: DiscoveryConfig { get } + var URIScheme: String { get } } public enum TokenType: String { @@ -42,6 +44,7 @@ private enum ConfigKeys: String { case organizationCode = "ORGANIZATION_CODE" case appstoreID = "APP_STORE_ID" case faq = "FAQ_URL" + case URIScheme = "URI_SCHEME" } public class Config { @@ -64,7 +67,7 @@ public class Config { let dict = try? PropertyListSerialization.propertyList( from: data, options: [], - format: nil) as? [String: Any] + format: nil) as? [String: Any] else { return } properties = dict @@ -149,6 +152,10 @@ extension Config: ConfigProtocol { } return url } + + public var URIScheme: String { + return string(for: ConfigKeys.URIScheme.rawValue) ?? "" + } } // Mark - For testing and SwiftUI preview diff --git a/Core/Core/Configuration/Config/DiscoveryConfig.swift b/Core/Core/Configuration/Config/DiscoveryConfig.swift index e464ba22..88480044 100644 --- a/Core/Core/Configuration/Config/DiscoveryConfig.swift +++ b/Core/Core/Configuration/Config/DiscoveryConfig.swift @@ -55,3 +55,10 @@ extension Config { DiscoveryConfig(dictionary: self[key] as? [String: AnyObject] ?? [:]) } } + +private let programKey = "PROGRAM" +extension Config { + public var program: DiscoveryConfig { + DiscoveryConfig(dictionary: self[programKey] as? [String: AnyObject] ?? [:]) + } +} diff --git a/Core/Core/View/Base/Webview/WebView.swift b/Core/Core/View/Base/Webview/WebView.swift index 43bf716c..db2e754e 100644 --- a/Core/Core/View/Base/Webview/WebView.swift +++ b/Core/Core/View/Base/Webview/WebView.swift @@ -11,7 +11,11 @@ import SwiftUI import Theme public protocol WebViewNavigationDelegate: AnyObject { - func webView(_ webView: WKWebView, shouldLoad request: URLRequest, navigationAction: WKNavigationAction) -> Bool + func webView( + _ webView: WKWebView, + shouldLoad request: URLRequest, + navigationAction: WKNavigationAction + ) async -> Bool } public struct WebView: UIViewRepresentable { diff --git a/Discovery/Discovery.xcodeproj/project.pbxproj b/Discovery/Discovery.xcodeproj/project.pbxproj index 8c3b3820..76937609 100644 --- a/Discovery/Discovery.xcodeproj/project.pbxproj +++ b/Discovery/Discovery.xcodeproj/project.pbxproj @@ -24,6 +24,8 @@ 02F1752F2A4DA3B60019CD70 /* DiscoveryAnalytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F1752E2A4DA3B60019CD70 /* DiscoveryAnalytics.swift */; }; 02F3BFDF29252F2F0051930C /* DiscoveryRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 02F3BFDE29252F2F0051930C /* DiscoveryRouter.swift */; }; 072787AD28D34D15002E9142 /* Core.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 072787AC28D34D15002E9142 /* Core.framework */; }; + 1402A0C92B61012F00A0A00B /* ProgramWebviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1402A0C72B61012F00A0A00B /* ProgramWebviewView.swift */; }; + 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 */; }; CFC8494C299A66080055E497 /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = CFC8494E299A66080055E497 /* Localizable.stringsdict */; }; @@ -76,6 +78,8 @@ 0727879928D34C03002E9142 /* Discovery.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Discovery.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 072787AC28D34D15002E9142 /* Core.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; path = Core.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 0C3850985F33C1AD72BF1B04 /* Pods-App-Discovery.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Discovery.release.xcconfig"; path = "Target Support Files/Pods-App-Discovery/Pods-App-Discovery.release.xcconfig"; sourceTree = ""; }; + 1402A0C72B61012F00A0A00B /* ProgramWebviewView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramWebviewView.swift; sourceTree = ""; }; + 1402A0C82B61012F00A0A00B /* ProgramWebviewViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramWebviewViewModel.swift; sourceTree = ""; }; 2334C76D248D0A95634AFFD9 /* Pods-App-Discovery-DiscoveryUnitTests.debugstage.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Discovery-DiscoveryUnitTests.debugstage.xcconfig"; path = "Target Support Files/Pods-App-Discovery-DiscoveryUnitTests/Pods-App-Discovery-DiscoveryUnitTests.debugstage.xcconfig"; sourceTree = ""; }; 2760B1F234E01FFCB73F41C2 /* Pods-App-Discovery.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Discovery.debug.xcconfig"; path = "Target Support Files/Pods-App-Discovery/Pods-App-Discovery.debug.xcconfig"; sourceTree = ""; }; 445F0675BF0E1DEB78F3CE73 /* Pods-App-Discovery-DiscoveryUnitTests.releasedev.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-App-Discovery-DiscoveryUnitTests.releasedev.xcconfig"; path = "Target Support Files/Pods-App-Discovery-DiscoveryUnitTests/Pods-App-Discovery-DiscoveryUnitTests.releasedev.xcconfig"; sourceTree = ""; }; @@ -200,6 +204,7 @@ 070019A228F6EF2700D5FC78 /* Presentation */ = { isa = PBXGroup; children = ( + 1402A0C62B61011D00A0A00B /* WebPrograms */, E0B9F6952B4D57F800168366 /* NativeDiscovery */, E0D5861D2B300095009B4BA7 /* WebDiscovery */, 029242E52AE6976E00A940EC /* UpdateViews */, @@ -266,6 +271,15 @@ path = Presentation; sourceTree = ""; }; + 1402A0C62B61011D00A0A00B /* WebPrograms */ = { + isa = PBXGroup; + children = ( + 1402A0C72B61012F00A0A00B /* ProgramWebviewView.swift */, + 1402A0C82B61012F00A0A00B /* ProgramWebviewViewModel.swift */, + ); + path = WebPrograms; + sourceTree = ""; + }; 88B044C704F7C52F249CC424 /* Pods */ = { isa = PBXGroup; children = ( @@ -548,10 +562,12 @@ 02F3BFDF29252F2F0051930C /* DiscoveryRouter.swift in Sources */, E0D586252B300134009B4BA7 /* URL+PathExtension.swift in Sources */, E0B9F6A62B4D620100168366 /* Data_CourseDetailsResponse.swift in Sources */, + 1402A0C92B61012F00A0A00B /* ProgramWebviewView.swift in Sources */, 029737422949FB3B0051696B /* DiscoveryPersistenceProtocol.swift in Sources */, 0284DC0328D4922900830893 /* DiscoveryRepository.swift in Sources */, 029242EB2AE6AB7B00A940EC /* UpdateNotificationView.swift in Sources */, 02EF39DC28D86BEF0058F6BD /* Strings.swift in Sources */, + 1402A0CA2B61012F00A0A00B /* ProgramWebviewViewModel.swift in Sources */, E0B9F69C2B4D57F800168366 /* SearchView.swift in Sources */, E0D586202B300095009B4BA7 /* DiscoveryWebviewViewModel.swift in Sources */, 02F1752F2A4DA3B60019CD70 /* DiscoveryAnalytics.swift in Sources */, diff --git a/Discovery/Discovery/Presentation/DiscoveryRouter.swift b/Discovery/Discovery/Presentation/DiscoveryRouter.swift index cf47d220..cca463e9 100644 --- a/Discovery/Discovery/Presentation/DiscoveryRouter.swift +++ b/Discovery/Discovery/Presentation/DiscoveryRouter.swift @@ -27,6 +27,11 @@ public protocol DiscoveryRouter: BaseRouter { enrollmentEnd: Date?, title: String ) + + func showWebProgramDetails( + pathID: String, + viewType: ProgramViewType + ) } // Mark - For testing and SwiftUI preview @@ -53,5 +58,10 @@ public class DiscoveryRouterMock: BaseRouterMock, DiscoveryRouter { enrollmentEnd: Date?, title: String ) {} + + public func showWebProgramDetails( + pathID: String, + viewType: ProgramViewType + ) {} } #endif diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryURIDetails.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryURIDetails.swift index 3eff65da..2fa9071b 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryURIDetails.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryURIDetails.swift @@ -10,7 +10,6 @@ import Core // Define your uri scheme public enum URIString: String { - case appURLScheme = "edxapp" case pathPlaceHolder = "{path_id}" } diff --git a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift index 69555a80..99cd937b 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/DiscoveryWebviewViewModel.swift @@ -111,15 +111,16 @@ public class DiscoveryWebviewViewModel: ObservableObject { } extension DiscoveryWebviewViewModel: WebViewNavigationDelegate { + @MainActor public func webView( _ webView: WKWebView, shouldLoad request: URLRequest, navigationAction: WKNavigationAction - ) -> Bool { + ) async -> Bool { guard let URL = request.url else { return false } if let urlAction = urlAction(from: URL), - handleNavigation(url: URL, urlAction: urlAction) { + await handleNavigation(url: URL, urlAction: urlAction) { return true } @@ -134,18 +135,16 @@ extension DiscoveryWebviewViewModel: WebViewNavigationDelegate { } if let url = request.url, outsideLink || capturedLink || externalLink, UIApplication.shared.canOpenURL(url) { - DispatchQueue.main.async { [weak self] in - self?.router.presentAlert( - alertTitle: DiscoveryLocalization.Alert.leavingAppTitle, - alertMessage: DiscoveryLocalization.Alert.leavingAppMessage, - positiveAction: CoreLocalization.Webview.Alert.continue, - onCloseTapped: { - self?.router.dismiss(animated: true) - }, okTapped: { - UIApplication.shared.open(url, options: [:]) - }, type: .default(positiveAction: CoreLocalization.Webview.Alert.continue, image: nil) - ) - } + router.presentAlert( + alertTitle: DiscoveryLocalization.Alert.leavingAppTitle, + alertMessage: DiscoveryLocalization.Alert.leavingAppMessage, + positiveAction: CoreLocalization.Webview.Alert.continue, + onCloseTapped: { [weak self] in + self?.router.dismiss(animated: true) + }, okTapped: { + UIApplication.shared.open(url, options: [:]) + }, type: .default(positiveAction: CoreLocalization.Webview.Alert.continue, image: nil) + ) return true } @@ -153,18 +152,17 @@ extension DiscoveryWebviewViewModel: WebViewNavigationDelegate { } private func urlAction(from url: URL) -> WebviewActions? { - guard url.isValidAppURLScheme, + guard isValidAppURLScheme(url), let url = WebviewActions(rawValue: url.appURLHost) else { return nil } return url } - private func handleNavigation(url: URL, urlAction: WebviewActions) -> Bool { + @MainActor + private func handleNavigation(url: URL, urlAction: WebviewActions) async -> Bool { switch urlAction { case .courseEnrollment: if let urlData = parse(url: url), let courseID = urlData.courseId { - Task { - await enrollTo(courseID: courseID) - } + await enrollTo(courseID: courseID) } case .courseDetail: guard let pathID = detailPathID(from: url) else { return false } @@ -192,7 +190,7 @@ extension DiscoveryWebviewViewModel: WebViewNavigationDelegate { } private func detailPathID(from url: URL) -> String? { - guard url.isValidAppURLScheme, + guard isValidAppURLScheme(url), let path = url.queryParameters?[URLParameterKeys.pathId] as? String, url.appURLHost == WebviewActions.courseDetail.rawValue else { return nil } @@ -200,7 +198,7 @@ extension DiscoveryWebviewViewModel: WebViewNavigationDelegate { } private func parse(url: URL) -> (courseId: String?, emailOptIn: Bool)? { - guard url.isValidAppURLScheme else { return nil } + guard isValidAppURLScheme(url) else { return nil } let courseId = url.queryParameters?[URLParameterKeys.courseId] as? String let emailOptIn = (url.queryParameters?[URLParameterKeys.emailOptIn] as? String).flatMap {Bool($0)} @@ -209,7 +207,7 @@ extension DiscoveryWebviewViewModel: WebViewNavigationDelegate { } private func programDetailPathId(from url: URL) -> String? { - guard url.isValidAppURLScheme, + guard isValidAppURLScheme(url), let path = url.queryParameters?[URLParameterKeys.pathId] as? String, url.appURLHost == WebviewActions.programDetail.rawValue else { return nil } @@ -231,4 +229,8 @@ extension DiscoveryWebviewViewModel: WebViewNavigationDelegate { return true } + + private func isValidAppURLScheme(_ url: URL) -> Bool { + return url.scheme ?? "" == config.URIScheme + } } diff --git a/Discovery/Discovery/Presentation/WebDiscovery/URL+PathExtension.swift b/Discovery/Discovery/Presentation/WebDiscovery/URL+PathExtension.swift index fb629e10..09cde088 100644 --- a/Discovery/Discovery/Presentation/WebDiscovery/URL+PathExtension.swift +++ b/Discovery/Discovery/Presentation/WebDiscovery/URL+PathExtension.swift @@ -12,10 +12,6 @@ public extension URL { return host ?? "" } - var isValidAppURLScheme: Bool { - return scheme ?? "" == URIString.appURLScheme.rawValue - } - var queryParameters: [String: Any]? { guard let queryString = query else { return nil diff --git a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift new file mode 100644 index 00000000..b396e31b --- /dev/null +++ b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift @@ -0,0 +1,108 @@ +// +// ProgramWebviewView.swift +// Discovery +// +// Created by SaeedBashir on 1/23/24. +// + +import Foundation +import SwiftUI +import Theme +import Core + +public enum ProgramViewType: Equatable { + case program + case programDetail +} + +public struct ProgramWebviewView: View { + @State private var isLoading: Bool = true + + @ObservedObject private var viewModel: ProgramWebviewViewModel + private var router: DiscoveryRouter + private var viewType: ProgramViewType + private var pathID: String + + private var URLString: String { + switch viewType { + case .program: + return viewModel.config.program.webview.baseURL ?? "" + case .programDetail: + let template = viewModel.config.program.webview.programDetailTemplate + return template?.replacingOccurrences( + of: URIString.pathPlaceHolder.rawValue, + with: pathID + ) ?? "" + } + } + + public init( + viewModel: ProgramWebviewViewModel, + router: DiscoveryRouter, + viewType: ProgramViewType = .program, + pathID: String = "" + ) { + self.viewModel = 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: "" + ), + isLoading: $isLoading, + refreshCookies: {}, + navigationDelegate: viewModel + ) + + if isLoading || viewModel.showProgress { + HStack(alignment: .center) { + ProgressBar( + size: 40, + lineWidth: 8 + ) + .padding(.vertical, proxy.size.height / 2) + } + .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 + } + } + } + } + + // MARK: - Offline mode SnackBar + OfflineSnackBarView( + connectivity: viewModel.connectivity, + reloadAction: { + NotificationCenter.default.post( + name: .webviewReloadNotification, + object: nil + ) + }) + } + .navigationBarHidden(viewType == .program) + .navigationTitle(CoreLocalization.Mainscreen.programs) + .background(Theme.Colors.background.ignoresSafeArea()) + } +} diff --git a/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift new file mode 100644 index 00000000..1f234b68 --- /dev/null +++ b/Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewViewModel.swift @@ -0,0 +1,232 @@ +// +// ProgramWebviewViewModel.swift +// Discovery +// +// Created by SaeedBashir on 1/23/24. +// + +import Foundation +import Core +import SwiftUI +import WebKit + +public class ProgramWebviewViewModel: ObservableObject { + @Published var courseDetails: CourseDetails? + @Published private(set) var showProgress = false + @Published var showError: Bool = false + var errorMessage: String? { + didSet { + withAnimation { + showError = errorMessage != nil + } + } + } + + let router: DiscoveryRouter + let config: ConfigProtocol + let connectivity: ConnectivityProtocol + private let interactor: DiscoveryInteractorProtocol + private let analytics: DiscoveryAnalytics + var request: URLRequest? + + 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 + } + + @MainActor + func getCourseDetail(courseID: String) async throws -> CourseDetails? { + return try await interactor.getCourseDetails(courseID: courseID) + } + + @MainActor + func enrollTo(courseID: String) async { + do { + showProgress = true + if courseDetails == nil { + courseDetails = try await getCourseDetail(courseID: courseID) + } + + if courseDetails?.isEnrolled ?? false || courseState == .alreadyEnrolled { + showProgress = false + showCourseDetails() + return + } + + analytics.courseEnrollClicked(courseId: courseID, courseName: courseDetails?.courseTitle ?? "") + _ = try await interactor.enrollToCourse(courseID: courseID) + analytics.courseEnrollSuccess(courseId: courseID, courseName: courseDetails?.courseTitle ?? "") + courseDetails?.isEnrolled = true + showProgress = false + NotificationCenter.default.post(name: .onCourseEnrolled, object: courseID) + showCourseDetails() + courseDetails = nil + } catch let error { + showProgress = false + if error.isInternetError || error is NoCachedDataError { + errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } else { + errorMessage = CoreLocalization.Error.unknownError + } + } + } + + private var courseState: CourseState? { + guard courseDetails?.isEnrolled == false else { return nil } + + if let enrollmentStart = courseDetails?.enrollmentStart, let enrollmentEnd = courseDetails?.enrollmentEnd { + let enrollmentsRange = DateInterval(start: enrollmentStart, end: enrollmentEnd) + if enrollmentsRange.contains(Date()) { + return .enrollOpen + } else { + return .enrollClose + } + } else { + return .enrollOpen + } + } +} + +extension ProgramWebviewViewModel: WebViewNavigationDelegate { + @MainActor + public func webView( + _ webView: WKWebView, + shouldLoad request: URLRequest, + navigationAction: WKNavigationAction + ) async -> Bool { + guard let URL = request.url else { return false } + + if let urlAction = urlAction(from: URL), + await handleNavigation(url: URL, urlAction: urlAction) { + 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) { + router.presentAlert( + alertTitle: DiscoveryLocalization.Alert.leavingAppTitle, + alertMessage: DiscoveryLocalization.Alert.leavingAppMessage, + positiveAction: CoreLocalization.Webview.Alert.continue, + onCloseTapped: { [weak self] in + self?.router.dismiss(animated: true) + }, okTapped: { + UIApplication.shared.open(url, options: [:]) + }, type: .default(positiveAction: CoreLocalization.Webview.Alert.continue, image: nil) + ) + return true + } + + return false + } + + private func urlAction(from url: URL) -> WebviewActions? { + guard isValidAppURLScheme(url), + let url = WebviewActions(rawValue: url.appURLHost) else { return nil } + return url + } + + @MainActor + private func handleNavigation(url: URL, urlAction: WebviewActions) async -> Bool { + switch urlAction { + case .courseEnrollment: + if let urlData = parse(url: url), let courseID = urlData.courseId { + await enrollTo(courseID: courseID) + } + case .courseDetail: + guard let pathID = detailPathID(from: url) else { return false } + router.showWebDiscoveryDetails( + pathID: pathID, + discoveryType: .courseDetail(pathID), + sourceScreen: .default + ) + case .enrolledCourseDetail: + if let urlData = parse(url: url), let courseID = urlData.courseId { + showProgress = true + do { + courseDetails = try await getCourseDetail(courseID: courseID) + showCourseDetails() + courseDetails = nil + showProgress = false + } catch let error { + showProgress = false + if error.isInternetError || error is NoCachedDataError { + errorMessage = CoreLocalization.Error.slowOrNoInternetConnection + } else { + errorMessage = CoreLocalization.Error.unknownError + } + } + } + + case .programDetail: + guard let pathID = detailPathID(from: url) else { return false } + router.showWebDiscoveryDetails( + pathID: pathID, + discoveryType: .programDetail(pathID), + sourceScreen: .default + ) + case .enrolledProgramDetail: + guard let pathID = detailPathID(from: url) else { return false } + router.showWebProgramDetails(pathID: pathID, viewType: .programDetail) + + default: + break + } + + return true + } + + private func detailPathID(from url: URL) -> String? { + guard isValidAppURLScheme(url), + let path = url.queryParameters?[URLParameterKeys.pathId] as? String + else { return nil } + + return path + } + + private func parse(url: URL) -> (courseId: String?, emailOptIn: Bool)? { + guard isValidAppURLScheme(url) 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) + } + + @discardableResult private func showCourseDetails() -> Bool { + guard let courseDetails = courseDetails else { return false } + + router.showCourseScreens( + courseID: courseDetails.courseID, + isActive: nil, + courseStart: courseDetails.courseStart, + courseEnd: courseDetails.courseEnd, + enrollmentStart: courseDetails.enrollmentStart, + enrollmentEnd: courseDetails.enrollmentEnd, + title: courseDetails.courseTitle + ) + + return true + } + + private func isValidAppURLScheme(_ url: URL) -> Bool { + return url.scheme ?? "" == config.URIScheme + } +} diff --git a/OpenEdX/DI/ScreenAssembly.swift b/OpenEdX/DI/ScreenAssembly.swift index 9709e39d..c236518a 100644 --- a/OpenEdX/DI/ScreenAssembly.swift +++ b/OpenEdX/DI/ScreenAssembly.swift @@ -121,6 +121,16 @@ class ScreenAssembly: Assembly { ) } + container.register(ProgramWebviewViewModel.self) { r in + ProgramWebviewViewModel( + 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/Router.swift b/OpenEdX/Router.swift index 72a8341f..52437e80 100644 --- a/OpenEdX/Router.swift +++ b/OpenEdX/Router.swift @@ -230,6 +230,20 @@ public class Router: AuthorizationRouter, } } + public func showWebProgramDetails( + pathID: String, + viewType: ProgramViewType + ) { + let view = ProgramWebviewView( + viewModel: Container.shared.resolve(ProgramWebviewViewModel.self)!, + router: Container.shared.resolve(DiscoveryRouter.self)!, + viewType: viewType, + pathID: pathID + ) + let controller = UIHostingController(rootView: view) + 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) diff --git a/OpenEdX/View/MainScreenView.swift b/OpenEdX/View/MainScreenView.swift index effc51c3..87335001 100644 --- a/OpenEdX/View/MainScreenView.swift +++ b/OpenEdX/View/MainScreenView.swift @@ -85,17 +85,27 @@ struct MainScreenView: View { } .tag(MainTab.dashboard) - ZStack { - Text(CoreLocalization.Mainscreen.inDeveloping) - if updateAvaliable { - UpdateNotificationView(config: viewModel.config) + 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) + } + + if updateAvaliable { + UpdateNotificationView(config: viewModel.config) + } } + .tabItem { + CoreAssets.programs.swiftUIImage.renderingMode(.template) + Text(CoreLocalization.Mainscreen.programs) + } + .tag(MainTab.programs) } - .tabItem { - CoreAssets.programs.swiftUIImage.renderingMode(.template) - Text(CoreLocalization.Mainscreen.programs) - } - .tag(MainTab.programs) VStack { ProfileView(