Skip to content

Commit

Permalink
feat: Enrolled Web Based Programs Implementation (openedx#260)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
saeedbashir authored Feb 2, 2024
1 parent 02fcec9 commit a03bb3e
Show file tree
Hide file tree
Showing 13 changed files with 453 additions and 38 deletions.
9 changes: 8 additions & 1 deletion Core/Core/Configuration/Config/Config.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions Core/Core/Configuration/Config/DiscoveryConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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] ?? [:])
}
}
6 changes: 5 additions & 1 deletion Core/Core/View/Base/Webview/WebView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
16 changes: 16 additions & 0 deletions Discovery/Discovery.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 = "<group>"; };
1402A0C72B61012F00A0A00B /* ProgramWebviewView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramWebviewView.swift; sourceTree = "<group>"; };
1402A0C82B61012F00A0A00B /* ProgramWebviewViewModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ProgramWebviewViewModel.swift; sourceTree = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
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 = "<group>"; };
Expand Down Expand Up @@ -200,6 +204,7 @@
070019A228F6EF2700D5FC78 /* Presentation */ = {
isa = PBXGroup;
children = (
1402A0C62B61011D00A0A00B /* WebPrograms */,
E0B9F6952B4D57F800168366 /* NativeDiscovery */,
E0D5861D2B300095009B4BA7 /* WebDiscovery */,
029242E52AE6976E00A940EC /* UpdateViews */,
Expand Down Expand Up @@ -266,6 +271,15 @@
path = Presentation;
sourceTree = "<group>";
};
1402A0C62B61011D00A0A00B /* WebPrograms */ = {
isa = PBXGroup;
children = (
1402A0C72B61012F00A0A00B /* ProgramWebviewView.swift */,
1402A0C82B61012F00A0A00B /* ProgramWebviewViewModel.swift */,
);
path = WebPrograms;
sourceTree = "<group>";
};
88B044C704F7C52F249CC424 /* Pods */ = {
isa = PBXGroup;
children = (
Expand Down Expand Up @@ -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 */,
Expand Down
10 changes: 10 additions & 0 deletions Discovery/Discovery/Presentation/DiscoveryRouter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,11 @@ public protocol DiscoveryRouter: BaseRouter {
enrollmentEnd: Date?,
title: String
)

func showWebProgramDetails(
pathID: String,
viewType: ProgramViewType
)
}

// Mark - For testing and SwiftUI preview
Expand All @@ -53,5 +58,10 @@ public class DiscoveryRouterMock: BaseRouterMock, DiscoveryRouter {
enrollmentEnd: Date?,
title: String
) {}

public func showWebProgramDetails(
pathID: String,
viewType: ProgramViewType
) {}
}
#endif
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import Core

// Define your uri scheme
public enum URIString: String {
case appURLScheme = "edxapp"
case pathPlaceHolder = "{path_id}"
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -134,37 +135,34 @@ 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
}

return false
}

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 }
Expand Down Expand Up @@ -192,15 +190,15 @@ 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 }

return path
}

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)}
Expand All @@ -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 }

Expand All @@ -231,4 +229,8 @@ extension DiscoveryWebviewViewModel: WebViewNavigationDelegate {

return true
}

private func isValidAppURLScheme(_ url: URL) -> Bool {
return url.scheme ?? "" == config.URIScheme
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
108 changes: 108 additions & 0 deletions Discovery/Discovery/Presentation/WebPrograms/ProgramWebviewView.swift
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading

0 comments on commit a03bb3e

Please sign in to comment.