├── NavigationEngineDemo
├── Assets.xcassets
│ ├── Contents.json
│ └── AppIcon.appiconset
│ │ └── Contents.json
├── NavigationEngineDemo.entitlements
├── NavigationEngine
│ ├── Core
│ │ ├── Protocol Extensions
│ │ │ ├── NSUserActivity+ProtocolExtension.swift
│ │ │ └── UIApplication+ProtocolExtension.swift
│ │ ├── Protocols
│ │ │ ├── NSUserActivityProtocol.swift
│ │ │ ├── UserStatusProviding.swift
│ │ │ ├── NavigationIntentHandling.swift
│ │ │ ├── UIApplicationProtocol.swift
│ │ │ ├── DeepLinkingSettingsProtocol.swift
│ │ │ └── FlowControllers
│ │ │ │ ├── SettingsFlowControllerProtocol.swift
│ │ │ │ ├── OrdersFlowControllerProtocol.swift
│ │ │ │ ├── AccountFlowControllerProtocol.swift
│ │ │ │ ├── CheckoutFlowControllerProtocol.swift
│ │ │ │ ├── RestaurantsFlowControllerProtocol.swift
│ │ │ │ └── RootFlowControllerProtocol.swift
│ │ ├── Utilities
│ │ │ └── Endpoint.swift
│ │ ├── DeepLinkingConstants.swift
│ │ ├── Navigation
│ │ │ ├── FlowControllerProvider.swift
│ │ │ ├── NavigationIntentFactory.swift
│ │ │ ├── StateMachine.swift
│ │ │ ├── NavigationTransitioner.swift
│ │ │ └── NavigationIntentHandler.swift
│ │ ├── Parsing
│ │ │ ├── SpotlightItemConverter.swift
│ │ │ ├── ShortcutItemConverter.swift
│ │ │ ├── URLGateway.swift
│ │ │ ├── DeepLinkFactory.swift
│ │ │ └── UniversalLinkConverter.swift
│ │ └── DeepLinkingFacade.swift
│ └── Tester
│ │ └── DeepLinkingTesterViewController.swift
├── DemoApp
│ ├── NavigationEngineIntegration
│ │ ├── UserStatusProvider.swift
│ │ ├── DeepLinkingSettings.swift
│ │ ├── deeplinking_test_list.json
│ │ └── FlowControllers
│ │ │ ├── SettingsFlowController.swift
│ │ │ ├── OrdersFlowController.swift
│ │ │ ├── CheckoutFlowController.swift
│ │ │ ├── AccountFlowController.swift
│ │ │ ├── RestaurantsFlowController.swift
│ │ │ └── RootFlowController.swift
│ ├── ViewControllers
│ │ ├── AccountViewController.swift
│ │ ├── PrivacyPolicyViewController.swift
│ │ ├── TabBarController.swift
│ │ ├── ResetPasswordViewController.swift
│ │ ├── SettingsViewController.swift
│ │ ├── HomeViewController.swift
│ │ ├── OrderHistoryViewController.swift
│ │ ├── CheckoutViewController.swift
│ │ ├── OrderDetailsViewController.swift
│ │ ├── RestaurantViewController.swift
│ │ ├── SERPViewController.swift
│ │ ├── BasketViewController.swift
│ │ └── PaymentViewController.swift
│ ├── Extensions
│ │ └── Storyboarding.swift
│ ├── UserInputController.swift
│ └── AppDelegate.swift
├── Base.lproj
│ └── LaunchScreen.storyboard
└── Info.plist
├── NavigationEngineDemo.xcodeproj
├── project.xcworkspace
│ └── contents.xcworkspacedata
├── xcuserdata
│ └── alberto.debortoli.xcuserdatad
│ │ └── xcschemes
│ │ └── xcschememanagement.plist
└── xcshareddata
│ └── xcschemes
│ └── NavigationEngineDemo.xcscheme
├── Podfile
├── NavigationEngineDemo.xcworkspace
├── contents.xcworkspacedata
└── xcshareddata
│ └── WorkspaceSettings.xcsettings
├── Podfile.lock
├── NavigationEngineDemoTests
├── Utilities
│ ├── MockUserStatusProvider.swift
│ ├── MockNSUserActivity.swift
│ ├── MockDeepLinkingSettings.swift
│ ├── MockNavigationTransitionerDataSource.swift
│ └── MockFlowControllers.swift
├── Info.plist
├── Navigation
│ ├── FlowControllerProviderTests.swift
│ ├── StateMachineTests.swift
│ ├── NavigationIntentHandlerTests.swift
│ └── NavigationIntentFactoryTests.swift
├── Parsing
│ ├── SpotlightItemConverterTests.swift
│ ├── ShortcutItemConverterTests.swift
│ ├── URLGatewayTests.swift
│ ├── DeepLinkFactoryTests.swift
│ └── UniversalLinkConverterTests.swift
└── DeepLinkingFacadeTests.swift
├── README.md
├── .gitignore
└── LICENSE
/NavigationEngineDemo/Assets.xcassets/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "info" : {
3 | "version" : 1,
4 | "author" : "xcode"
5 | }
6 | }
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngineDemo.entitlements:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/NavigationEngineDemo.xcodeproj/project.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/Podfile:
--------------------------------------------------------------------------------
1 | source 'https://github.com/CocoaPods/Specs.git'
2 |
3 | use_frameworks!
4 | platform :ios, '10.0'
5 |
6 | target 'NavigationEngineDemo' do
7 | pod 'Promis', '2.2.0'
8 | pod 'Stateful', '2.2.0'
9 |
10 | target 'NavigationEngineDemoTests' do
11 | inherit! :search_paths
12 | end
13 |
14 | end
15 |
--------------------------------------------------------------------------------
/NavigationEngineDemo.xcworkspace/contents.xcworkspacedata:
--------------------------------------------------------------------------------
1 |
2 |
4 |
6 |
7 |
9 |
10 |
11 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Protocol Extensions/NSUserActivity+ProtocolExtension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSUserActivity+ProtocolExtension.swift
3 | // NavigationEngine
4 | //
5 | // Created by Alberto De Bortoli on 11/02/2019.
6 | //
7 |
8 | import Foundation
9 |
10 | extension NSUserActivity: NSUserActivityProtocol { }
11 |
--------------------------------------------------------------------------------
/NavigationEngineDemo.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | BuildSystemType
6 | Original
7 |
8 |
9 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Protocol Extensions/UIApplication+ProtocolExtension.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIApplication+ProtocolExtension.swift
3 | // JUSTEAT
4 | //
5 | // Created by Alberto De Bortoli on 28/04/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | extension UIApplication: UIApplicationProtocol {}
12 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Protocols/NSUserActivityProtocol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NSUserActivityProtocol.swift
3 | // NavigationEngine
4 | //
5 | // Created by Alberto De Bortoli on 11/02/2019.
6 | //
7 |
8 | import Foundation
9 |
10 | public protocol NSUserActivityProtocol {
11 |
12 | var activityType: String { get }
13 | var userInfo: [AnyHashable: Any]? { get }
14 | }
15 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Protocols/UserStatusProviding.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserStatusProviding.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 04/02/2019.
6 | // Copyright © 2019 Just Eat. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public protocol UserStatusProviding {
12 |
13 | var userStatus: UserStatus { get }
14 | }
15 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/NavigationEngineIntegration/UserStatusProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserStatusProvider.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 04/02/2019.
6 | // Copyright © 2019 Just Eat. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | class UserStatusProvider: UserStatusProviding {
12 |
13 | var userStatus: UserStatus = .loggedOut
14 | }
15 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Protocols/NavigationIntentHandling.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NavigationIntentHandling.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 28/12/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import Promis
11 |
12 | protocol NavigationIntentHandling {
13 |
14 | func handleIntent(_ intent: NavigationIntent) -> Future
15 | }
16 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Protocols/UIApplicationProtocol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UIApplicationProtocol.swift
3 | // Location
4 | //
5 | // Created by Alberto De Bortoli on 06/02/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | protocol UIApplicationProtocol {
12 |
13 | func open(_ url: URL, options: [UIApplication.OpenExternalURLOptionsKey: Any], completionHandler completion: ((Bool) -> Void)?)
14 | }
15 |
--------------------------------------------------------------------------------
/Podfile.lock:
--------------------------------------------------------------------------------
1 | PODS:
2 | - Promis (2.2.0)
3 | - Stateful (2.2.0)
4 |
5 | DEPENDENCIES:
6 | - Promis (= 2.2.0)
7 | - Stateful (= 2.2.0)
8 |
9 | SPEC REPOS:
10 | https://github.com/cocoapods/specs.git:
11 | - Promis
12 | - Stateful
13 |
14 | SPEC CHECKSUMS:
15 | Promis: 4b7c710108427d361e29a2be352dc0eff5315456
16 | Stateful: 92e01259d6e0b54af8ee4536ec50430b3d378063
17 |
18 | PODFILE CHECKSUM: e8dcc578ec330fe55ad76341a832a280f738e508
19 |
20 | COCOAPODS: 1.6.1
21 |
--------------------------------------------------------------------------------
/NavigationEngineDemoTests/Utilities/MockUserStatusProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MockUserStatusProvider.swift
3 | // NavigationEngine-Unit-Tests
4 | //
5 | // Created by Alberto De Bortoli on 12/02/2019.
6 | //
7 |
8 | import Foundation
9 | @testable import NavigationEngineDemo
10 |
11 | class MockUserStatusProvider: UserStatusProviding {
12 |
13 | var userStatus: UserStatus
14 |
15 | init(userStatus: UserStatus) {
16 | self.userStatus = userStatus
17 | }
18 | }
19 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # NavigationEngineDemo
2 |
3 | This is a sample project companion to the blog post "Deep Linking at Scale on iOS", that can be found at
4 |
5 | - [Just Eat Tech blog (WordPress)](https://tech.just-eat.com/2019/04/16/deep-linking-at-scale-on-ios/)
6 | - [Just Eat Engineering blog (Medium)](https://medium.com/just-eat-tech/deep-linking-at-scale-on-ios-1dd8789c389f)
7 |
8 | ## License
9 |
10 | NavigationEngineDemo is available under the Apache 2.0 license. See the LICENSE file for more info.
11 |
12 | - Just Eat iOS team
13 |
--------------------------------------------------------------------------------
/NavigationEngineDemoTests/Utilities/MockNSUserActivity.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MockNSUserActivity.swift
3 | // NavigationEngine
4 | //
5 | // Created by Alberto De Bortoli on 11/02/2019.
6 | //
7 |
8 | import Foundation
9 | @testable import NavigationEngineDemo
10 |
11 | class MockNSUserActivity: NSUserActivityProtocol {
12 |
13 | var activityType: String
14 | var userInfo: [AnyHashable : Any]?
15 |
16 | init(activityType: String, userInfo: [AnyHashable : Any]?) {
17 | self.activityType = activityType
18 | self.userInfo = userInfo
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Protocols/DeepLinkingSettingsProtocol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DeepLinkingSettingsProtocol.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 04/02/2019.
6 | // Copyright © 2019 Just Eat. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public protocol DeepLinkingSettingsProtocol {
12 |
13 | var universalLinkSchemes: [String] { get }
14 | var universalLinkHosts: [String] { get }
15 |
16 | var internalDeepLinkSchemes: [String] { get }
17 | var internalDeepLinkHost: String { get }
18 | }
19 |
--------------------------------------------------------------------------------
/NavigationEngineDemoTests/Utilities/MockDeepLinkingSettings.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MockDeepLinkingSettings.swift
3 | // Pods
4 | //
5 | // Created by Alberto De Bortoli on 10/02/2019.
6 | //
7 |
8 | import Foundation
9 | import NavigationEngineDemo
10 |
11 | class MockDeepLinkingSettings: DeepLinkingSettingsProtocol {
12 |
13 | var universalLinkSchemes = ["http", "https"]
14 | var universalLinkHosts = ["just-eat.co.uk", "www.just-eat.co.uk"]
15 |
16 | var internalDeepLinkSchemes = ["je-internal", "justeat", "just-eat", "just-eat-uk"]
17 | var internalDeepLinkHost = "je.com"
18 | }
19 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/ViewControllers/AccountViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LoginViewController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 05/02/2019.
6 | // Copyright © 2019 Just Eat. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import UIKit
11 |
12 | class LoginViewController: UIViewController, Storyboarding {
13 |
14 | var flowController: AccountFlowController!
15 |
16 | override func viewDidLoad() {
17 | super.viewDidLoad()
18 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!)
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/ViewControllers/PrivacyPolicyViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PrivacyPolicyViewController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 23/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class PrivacyPolicyViewController: UIViewController, Storyboarding {
12 |
13 | var flowController: SettingsFlowController!
14 |
15 | override func viewDidLoad() {
16 | super.viewDidLoad()
17 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!)
18 | }
19 | }
20 |
21 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/ViewControllers/TabBarController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // TabBarController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 23/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class TabBarController: UITabBarController, Storyboarding {
12 |
13 | weak var flowController: RootFlowController!
14 |
15 | override func viewDidLoad() {
16 | super.viewDidLoad()
17 | self.title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!)
18 | flowController.setup()
19 | }
20 | }
21 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Protocols/FlowControllers/SettingsFlowControllerProtocol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingsFlowController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 23/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import Promis
11 |
12 | public protocol SettingsFlowControllerProtocol {
13 |
14 | var parentFlowController: RootFlowControllerProtocol! { get }
15 |
16 | @discardableResult func dismiss(animated: Bool) -> Future
17 | @discardableResult func goBackToRoot(animated: Bool) -> Future
18 | }
19 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/NavigationEngineIntegration/DeepLinkingSettings.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DeepLinkingSettings.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 04/02/2019.
6 | // Copyright © 2019 Just Eat. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | class DeepLinkingSettings: DeepLinkingSettingsProtocol {
12 |
13 | var universalLinkSchemes = ["http", "https"]
14 | var universalLinkHosts = ["just-eat.co.uk", "www.just-eat.co.uk"]
15 |
16 | var internalDeepLinkSchemes = ["je-internal", "justeat", "just-eat", "just-eat-uk"]
17 | var internalDeepLinkHost = "je.com"
18 | }
19 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/ViewControllers/ResetPasswordViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ResetPasswordViewController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 05/02/2019.
6 | // Copyright © 2019 Just Eat. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import UIKit
11 |
12 | class ResetPasswordViewController: UIViewController, Storyboarding {
13 |
14 | var flowController: AccountFlowController!
15 | var token: ResetPasswordToken!
16 |
17 | override func viewDidLoad() {
18 | super.viewDidLoad()
19 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!)
20 | }
21 | }
22 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/ViewControllers/SettingsViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingsViewController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 23/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class SettingsViewController: UIViewController {
12 |
13 | var flowController: SettingsFlowController!
14 |
15 | override func viewDidLoad() {
16 | super.viewDidLoad()
17 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!)
18 | }
19 |
20 | @IBAction func privacyPolicySelected(_ sender: Any) {
21 | flowController.goToPrivacyPolicy()
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/ViewControllers/HomeViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // HomeViewController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 23/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class HomeViewController: UIViewController {
12 |
13 | var flowController: RestaurantsFlowController!
14 |
15 | override func viewDidLoad() {
16 | super.viewDidLoad()
17 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!)
18 | }
19 |
20 | @IBAction func serpSelected(_ sender: Any) {
21 | flowController.goToSearchAnimated(postcode: "", cuisine: "", animated: true)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/ViewControllers/OrderHistoryViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OrderHistoryViewController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 23/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class OrderHistoryViewController: UIViewController {
12 |
13 | var flowController: OrdersFlowController!
14 |
15 | override func viewDidLoad() {
16 | super.viewDidLoad()
17 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!)
18 | }
19 |
20 | @IBAction func orderSelected(_ sender: Any) {
21 | flowController.goToOrder(orderId: "", animated: true)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Utilities/Endpoint.swift:
--------------------------------------------------------------------------------
1 | //
2 | // LoginViewController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 05/02/2019.
6 | // Copyright © 2019 Just Eat. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | struct Endpoint {
12 | let scheme: String
13 | let host: String
14 | let path: String
15 | let queryItems: [URLQueryItem]?
16 | }
17 |
18 | extension Endpoint {
19 | var url: URL {
20 | var components = URLComponents()
21 | components.scheme = scheme
22 | components.host = host
23 | components.path = path
24 | components.queryItems = queryItems
25 | return components.url!
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/ViewControllers/CheckoutViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CheckoutViewController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 23/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class CheckoutViewController: UIViewController, Storyboarding {
12 |
13 | var flowController: CheckoutFlowController!
14 |
15 | override func viewDidLoad() {
16 | super.viewDidLoad()
17 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!)
18 | }
19 |
20 | @IBAction func proceedToPaymentButtonTapped(_ sender: Any) {
21 | flowController.proceedToPayment(animated: true)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/Extensions/Storyboarding.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Storyboarding.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 04/01/2019.
6 | // Copyright © 2019 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | protocol Storyboarding {
12 | static func instantiate() -> Self
13 | }
14 |
15 | extension Storyboarding where Self: UIViewController {
16 | static func instantiate() -> Self {
17 | let fullName = NSStringFromClass(self)
18 | let className = fullName.components(separatedBy: ".")[1]
19 | let storyboard = UIStoryboard(name: "Main", bundle: Bundle.main)
20 | return storyboard.instantiateViewController(withIdentifier: className) as! Self
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/ViewControllers/OrderDetailsViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OrderDetailsViewController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 23/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class OrderDetailsViewController: UIViewController, Storyboarding {
12 |
13 | var flowController: OrdersFlowController!
14 |
15 | override func viewDidLoad() {
16 | super.viewDidLoad()
17 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!)
18 | }
19 |
20 | @IBAction func goToRestaurantButtonTapped(_ sender: Any) {
21 | flowController.goToRestaurant(restaurantId: "")
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/ViewControllers/RestaurantViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RestaurantViewController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 23/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class RestaurantViewController: UIViewController, Storyboarding {
12 |
13 | var flowController: RestaurantsFlowController!
14 |
15 | override func viewDidLoad() {
16 | super.viewDidLoad()
17 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!)
18 | }
19 |
20 | @IBAction func basketButtonSelected(_ sender: Any) {
21 | flowController.goToBasket(reorderId: "", animated: true)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/ViewControllers/SERPViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SerpViewController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 23/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class SerpViewController: UIViewController, Storyboarding {
12 |
13 | var flowController: RestaurantsFlowController!
14 |
15 | override func viewDidLoad() {
16 | super.viewDidLoad()
17 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!)
18 | }
19 |
20 | @IBAction func restaurantSelected(_ sender: Any) {
21 | flowController.goToRestaurant(restaurantId: "", postcode: nil, serviceType: nil, section: nil, animated: true)
22 | }
23 | }
24 |
--------------------------------------------------------------------------------
/NavigationEngineDemo.xcodeproj/xcuserdata/alberto.debortoli.xcuserdatad/xcschemes/xcschememanagement.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | SchemeUserState
6 |
7 | NavigationEngineDemo.xcscheme_^#shared#^_
8 |
9 | orderHint
10 | 4
11 |
12 |
13 | SuppressBuildableAutocreation
14 |
15 | 4F4F65F5223B025000AE1D03
16 |
17 | primary
18 |
19 |
20 | 4F4F6609223B025200AE1D03
21 |
22 | primary
23 |
24 |
25 |
26 |
27 |
28 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/ViewControllers/BasketViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // BasketViewController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 23/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class BasketViewController: UIViewController, Storyboarding {
12 |
13 | var flowController: RestaurantsFlowController!
14 | private var checkoutFlowController: CheckoutFlowController!
15 |
16 | override func viewDidLoad() {
17 | super.viewDidLoad()
18 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!)
19 | }
20 |
21 | @IBAction func checkoutButtonSelected(_ sender: Any) {
22 | flowController.goToCheckout(animated: true)
23 | }
24 | }
25 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Protocols/FlowControllers/OrdersFlowControllerProtocol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OrdersFlowController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 23/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import Promis
11 |
12 | public protocol OrdersFlowControllerProtocol {
13 |
14 | var parentFlowController: RootFlowControllerProtocol! { get }
15 |
16 | @discardableResult func dismiss(animated: Bool) -> Future
17 | @discardableResult func goBackToRoot(animated: Bool) -> Future
18 | @discardableResult func goToOrder(orderId: OrderId, animated: Bool) -> Future
19 | @discardableResult func goToRestaurant(restaurantId: RestaurantId) -> Future
20 | }
21 |
--------------------------------------------------------------------------------
/NavigationEngineDemoTests/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | BNDL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 |
22 |
23 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/ViewControllers/PaymentViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // PaymentViewController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 23/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | class PaymentViewController: UIViewController, Storyboarding {
12 |
13 | var flowController: CheckoutFlowController!
14 |
15 | override func viewDidLoad() {
16 | super.viewDidLoad()
17 | title = String(NSStringFromClass(type(of: self)).split(separator: ".").last!)
18 | }
19 |
20 | @IBAction func payButtonTapped(_ sender: Any) {
21 | flowController.goToOrderConfirmation(orderId: "")
22 | }
23 |
24 | @IBAction func quitButtonTapped(_ sender: Any) {
25 | flowController.leaveFlowAndGoBackToSERP()
26 | }
27 | }
28 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Protocols/FlowControllers/AccountFlowControllerProtocol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AccountFlowControllerProtocol.swift
3 | // NavigationEngine
4 | //
5 | // Created by Alberto De Bortoli on 05/02/2019.
6 | //
7 |
8 | import Foundation
9 | import Promis
10 |
11 | public protocol AccountFlowControllerProtocol {
12 |
13 | var parentFlowController: RootFlowControllerProtocol! { get }
14 |
15 | @discardableResult func beginLoginFlow(from viewController: UIViewController, animated: Bool) -> Future
16 | @discardableResult func waitForUserToLogin(from viewController: UIViewController, animated: Bool) -> Future
17 | @discardableResult func beginResetPasswordFlow(from viewController: UIViewController, token: ResetPasswordToken, animated: Bool) -> Future
18 | @discardableResult func leaveFlow(animated: Bool) -> Future
19 | }
20 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Protocols/FlowControllers/CheckoutFlowControllerProtocol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CheckoutFlowController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 23/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import Promis
11 |
12 | public protocol CheckoutFlowControllerProtocol {
13 |
14 | var parentFlowController: RestaurantsFlowControllerProtocol! { get }
15 |
16 | @discardableResult func beginFlow(from viewController: UIViewController, animated: Bool) -> Future
17 | @discardableResult func proceedToPayment(animated: Bool) -> Future
18 | @discardableResult func goToOrderConfirmation(orderId: OrderId) -> Future
19 | @discardableResult func leaveFlow(animated: Bool) -> Future
20 | @discardableResult func leaveFlowAndGoBackToSERP() -> Future
21 | }
22 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # OS X
2 | .DS_Store
3 |
4 | # Xcode
5 | build/
6 | *.pbxuser
7 | !default.pbxuser
8 | *.mode1v3
9 | !default.mode1v3
10 | *.mode2v3
11 | !default.mode2v3
12 | *.perspectivev3
13 | !default.perspectivev3
14 | xcuserdata/
15 | *.xccheckout
16 | profile
17 | *.moved-aside
18 | DerivedData
19 | *.hmap
20 | *.ipa
21 | *.dSYM.zip
22 |
23 | # Bundler
24 | .bundle
25 | fastlane/report.xml
26 | fastlane/test_output/
27 |
28 | # Add this line if you want to avoid checking in source code from Carthage dependencies.
29 | # Carthage/Checkouts
30 |
31 | Carthage/Build
32 |
33 | # We recommend against adding the Pods directory to your .gitignore. However
34 | # you should judge for yourself, the pros and cons are mentioned at:
35 | # http://guides.cocoapods.org/using/using-cocoapods.html#should-i-ignore-the-pods-directory-in-source-control
36 | #
37 | # Note: if you ignore the Pods directory, make sure to uncomment
38 | # `pod install` in .travis.yml
39 | #
40 | Pods/
41 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/DeepLinkingConstants.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DeepLinkingConstants.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 04/02/2019.
6 | // Copyright © 2019 Just Eat. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public typealias DeepLink = URL
12 | public typealias UniversalLink = URL
13 |
14 | public enum UserStatus: CaseIterable {
15 | case loggedIn
16 | case loggedOut
17 | }
18 |
19 | public enum RestaurantSection: String {
20 | case menu
21 | case reviews
22 | case info
23 | }
24 |
25 | public struct ReorderInfo {
26 | public let restaurantId: RestaurantId
27 | public let postcode: Postcode?
28 | public let serviceType: ServiceType?
29 |
30 | public init(restaurantId: RestaurantId, postcode: Postcode?, serviceType: ServiceType?) {
31 | self.restaurantId = restaurantId
32 | self.postcode = postcode
33 | self.serviceType = serviceType
34 | }
35 | }
36 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Protocols/FlowControllers/RestaurantsFlowControllerProtocol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RestaurantsFlowController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 23/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import Promis
11 |
12 | public protocol RestaurantsFlowControllerProtocol {
13 |
14 | var checkoutFlowController: CheckoutFlowControllerProtocol! { get }
15 | var parentFlowController: RootFlowControllerProtocol! { get }
16 |
17 | @discardableResult func dismiss(animated: Bool) -> Future
18 | @discardableResult func goBackToRoot(animated: Bool) -> Future
19 | @discardableResult func goToSearchAnimated(postcode: Postcode?, cuisine: Cuisine?, animated: Bool) -> Future
20 | @discardableResult func goToRestaurant(restaurantId: RestaurantId, postcode: Postcode?, serviceType: ServiceType?, section: RestaurantSection?, animated: Bool) -> Future
21 | @discardableResult func goToBasket(reorderId: ReorderId, animated: Bool) -> Future
22 | @discardableResult func goToCheckout(animated: Bool) -> Future
23 | }
24 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Navigation/FlowControllerProvider.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FlowControllerProvider.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 25/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | public class FlowControllerProvider {
12 |
13 | public let rootFlowController: RootFlowControllerProtocol
14 |
15 | public var restaurantsFlowController: RestaurantsFlowControllerProtocol {
16 | return rootFlowController.restaurantsFlowController
17 | }
18 |
19 | public var ordersFlowController: OrdersFlowControllerProtocol {
20 | return rootFlowController.ordersFlowController
21 | }
22 |
23 | public var checkoutFlowController: CheckoutFlowControllerProtocol {
24 | return rootFlowController.restaurantsFlowController.checkoutFlowController
25 | }
26 |
27 | public var settingsFlowController: SettingsFlowControllerProtocol {
28 | return rootFlowController.settingsFlowController
29 | }
30 |
31 | public init(rootFlowController: RootFlowControllerProtocol) {
32 | self.rootFlowController = rootFlowController
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Protocols/FlowControllers/RootFlowControllerProtocol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RootFlowController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 23/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import Promis
11 |
12 | public protocol RootFlowControllerProtocol {
13 |
14 | var restaurantsFlowController: RestaurantsFlowControllerProtocol! { get }
15 | var ordersFlowController: OrdersFlowControllerProtocol! { get }
16 | var settingsFlowController: SettingsFlowControllerProtocol! { get }
17 | var accountFlowController: AccountFlowControllerProtocol! { get }
18 |
19 | func setup()
20 |
21 | @discardableResult func dismissAll(animated: Bool) -> Future
22 | @discardableResult func dismissAndPopToRootAll(animated: Bool) -> Future
23 | @discardableResult func goToLogin(animated: Bool) -> Future
24 | @discardableResult func goToResetPassword(token: ResetPasswordToken, animated: Bool) -> Future
25 | @discardableResult func goToRestaurantsSection() -> Future
26 | @discardableResult func goToOrdersSection() -> Future
27 | @discardableResult func goToSettingsSection() -> Future
28 | }
29 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/NavigationEngineIntegration/deeplinking_test_list.json:
--------------------------------------------------------------------------------
1 | {
2 | "universal_links": [
3 | "https://just-eat.co.uk/",
4 | "https://just-eat.co.uk/home",
5 | "https://just-eat.co.uk/login",
6 | "https://just-eat.co.uk/area/EC4M7RF",
7 | "https://just-eat.co.uk/search?postcode=EC4M7RF",
8 | "https://just-eat.co.uk/search?postcode=EC4M7RF&cuisine=italian",
9 | "https://just-eat.co.uk/restaurant?restaurantId=123",
10 | "https://just-eat.co.uk/resetPassword?resetToken=123",
11 | "https://just-eat.co.uk/orders",
12 | "https://just-eat.co.uk/order/123",
13 | "https://just-eat.co.uk/reorder/123",
14 | "https://just-eat.co.uk/reorder?orderId=123",
15 | "https://just-eat.co.uk/account/update-password?token=123"
16 | ],
17 | "deep_links": [
18 | "JUSTEAT://irrelev.ant/home",
19 | "justeat://irrelev.ant/login",
20 | "just-eat-uk://irrelev.ant/resetPassword?resetToken=xyz",
21 | "je-internal://irrelev.ant/search?postcode=EC4M7RF",
22 | "je-internal://irrelev.ant/search?postcode=EC4M7RF&cuisine=italian",
23 | "je-internal://irrelev.ant/restaurant?restaurantId=12345",
24 | "je-internal://irrelev.ant/order-history",
25 | "je-internal://irrelev.ant/order?orderId=98765",
26 | "je-internal://irrelev.ant/reorder?orderId=x8hawCy",
27 | "je-internal://irrelev.ant/settings",
28 | "je-internal://irrelev.ant/unknown"
29 | ]
30 | }
31 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Parsing/SpotlightItemConverter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SpotlightItemConverter.swift
3 | // NavigationEngine
4 | //
5 | // Created by Alberto De Bortoli on 05/02/2019.
6 | //
7 |
8 | import Foundation
9 | import CoreSpotlight
10 |
11 | class SpotlightItemConverter {
12 |
13 | private lazy var deepLinkFactory: DeepLinkFactory = {
14 | return DeepLinkFactory(scheme: settings.internalDeepLinkSchemes.first!, host: settings.internalDeepLinkHost)
15 | }()
16 |
17 | private let settings: DeepLinkingSettingsProtocol
18 |
19 | init(settings: DeepLinkingSettingsProtocol) {
20 | self.settings = settings
21 | }
22 |
23 | func deepLink(forSpotlightItem userActivity: NSUserActivityProtocol) -> DeepLink? {
24 | guard userActivity.activityType == CSSearchableItemActionType, let uniqueIdentifier = userActivity.userInfo?[CSSearchableItemActivityIdentifier] as? String else { return nil }
25 |
26 | let components = uniqueIdentifier.components(separatedBy: "/")
27 |
28 | switch components.count {
29 | case 3:
30 | switch (components[1], components[2]) {
31 | case ("restaurant", let restaurantId):
32 | return deepLinkFactory.restaurantURL(restaurantId: restaurantId, postcode: nil, serviceType: nil, section: nil)
33 | case ("orderDetails", let orderId):
34 | return deepLinkFactory.orderDetailsURL(orderId: orderId)
35 | default:
36 | return nil
37 | }
38 |
39 | default:
40 | return nil
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/NavigationEngineDemoTests/Utilities/MockNavigationTransitionerDataSource.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MockNavigationTransitionerDataSource.swift
3 | // NavigationEngine-Unit-Tests
4 | //
5 | // Created by Alberto De Bortoli on 11/02/2019.
6 | //
7 |
8 | import Foundation
9 | import Promis
10 | @testable import NavigationEngineDemo
11 |
12 | class MockNavigationTransitionerDataSource: NavigationTransitionerDataSource {
13 |
14 | private let shouldSucceed: Bool
15 | private let userStatusProvider: MockUserStatusProvider?
16 |
17 | init(shouldSucceed: Bool, userStatusProvider: MockUserStatusProvider? = nil) {
18 | self.shouldSucceed = shouldSucceed
19 | self.userStatusProvider = userStatusProvider
20 | }
21 |
22 | func navigationTransitionerDidRequestInputForReorder(orderId: OrderId) -> Future {
23 | if shouldSucceed {
24 | let reorderInfo = ReorderInfo(restaurantId: "", postcode: "", serviceType: .delivery)
25 | return Future.future(withResult: reorderInfo)
26 | }
27 | else {
28 | let error = NSError(domain: "", code: 0, userInfo: nil)
29 | return Future.future(withError: error)
30 | }
31 | }
32 |
33 | func navigationTransitionerDidRequestUserToLogin() -> Future {
34 | if shouldSucceed {
35 | userStatusProvider?.userStatus = .loggedIn
36 | return Future.future(withResult: true)
37 | }
38 | else {
39 | let error = NSError(domain: "", code: 0, userInfo: nil)
40 | return Future.future(withError: error)
41 | }
42 | }
43 | }
44 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/NavigationEngineIntegration/FlowControllers/SettingsFlowController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SettingsFlowController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 23/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import Promis
11 |
12 | class SettingsFlowController: SettingsFlowControllerProtocol {
13 |
14 | private let navigationController: UINavigationController
15 | public let parentFlowController: RootFlowControllerProtocol!
16 |
17 | init(with parentFlowController: RootFlowControllerProtocol, navigationController: UINavigationController) {
18 | self.parentFlowController = parentFlowController
19 | self.navigationController = navigationController
20 | }
21 |
22 | @discardableResult
23 | func dismiss(animated: Bool) -> Future {
24 | let promise = Promise()
25 | navigationController.dismiss(animated: animated)
26 | // cannot use the completion to fulfill the future since it might never get called
27 | promise.setResult(true)
28 | return promise.future
29 | }
30 |
31 | @discardableResult
32 | func goBackToRoot(animated: Bool) -> Future {
33 | navigationController.popToRootViewController(animated: animated)
34 | return Future.future(withResult: true)
35 | }
36 |
37 | @discardableResult
38 | func goToPrivacyPolicy() -> Future {
39 | let privacyPolicy = PrivacyPolicyViewController.instantiate()
40 | privacyPolicy.flowController = self
41 | navigationController.pushViewController(privacyPolicy, animated: true)
42 | return Future.future(withResult: true)
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/Base.lproj/LaunchScreen.storyboard:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Parsing/ShortcutItemConverter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ShortcutItemConverter.swift
3 | // NavigationEngine
4 | //
5 | // Created by Alberto De Bortoli on 05/02/2019.
6 | //
7 |
8 | import Foundation
9 | import UIKit
10 |
11 | class ShortcutItemConverter {
12 |
13 | private lazy var deepLinkFactory: DeepLinkFactory = {
14 | return DeepLinkFactory(scheme: settings.internalDeepLinkSchemes.first!, host: settings.internalDeepLinkHost)
15 | }()
16 |
17 | private let settings: DeepLinkingSettingsProtocol
18 |
19 | init(settings: DeepLinkingSettingsProtocol) {
20 | self.settings = settings
21 | }
22 |
23 | func deepLink(forShortcutItem item: UIApplicationShortcutItem) -> DeepLink? {
24 |
25 | let components = item.type.components(separatedBy: "/")
26 |
27 | switch components.count {
28 | case 3:
29 | switch (components[1], components[2]) {
30 | case ("reorder", let orderId):
31 | return deepLinkFactory.reorderURL(orderId: orderId)
32 | case ("search", let postcode):
33 | return deepLinkFactory.searchURL(postcode: postcode, cuisine: nil, location: nil)
34 | default:
35 | return nil
36 | }
37 |
38 | case 2:
39 | switch components[1] {
40 | case "search":
41 | return deepLinkFactory.searchURL(postcode: nil, cuisine: nil, location: nil)
42 | case "orderHistory":
43 | return deepLinkFactory.orderHistoryURL()
44 | default:
45 | return nil
46 | }
47 |
48 | default:
49 | return nil
50 | }
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/Info.plist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | CFBundleDevelopmentRegion
6 | $(DEVELOPMENT_LANGUAGE)
7 | CFBundleExecutable
8 | $(EXECUTABLE_NAME)
9 | CFBundleIdentifier
10 | $(PRODUCT_BUNDLE_IDENTIFIER)
11 | CFBundleInfoDictionaryVersion
12 | 6.0
13 | CFBundleName
14 | $(PRODUCT_NAME)
15 | CFBundlePackageType
16 | APPL
17 | CFBundleShortVersionString
18 | 1.0
19 | CFBundleVersion
20 | 1
21 | LSRequiresIPhoneOS
22 |
23 | UILaunchStoryboardName
24 | LaunchScreen
25 | UIMainStoryboardFile
26 | Main
27 | UIRequiredDeviceCapabilities
28 |
29 | armv7
30 |
31 | UISupportedInterfaceOrientations
32 |
33 | UIInterfaceOrientationPortrait
34 | UIInterfaceOrientationLandscapeLeft
35 | UIInterfaceOrientationLandscapeRight
36 |
37 | UISupportedInterfaceOrientations~ipad
38 |
39 | UIInterfaceOrientationPortrait
40 | UIInterfaceOrientationPortraitUpsideDown
41 | UIInterfaceOrientationLandscapeLeft
42 | UIInterfaceOrientationLandscapeRight
43 |
44 | CFBundleURLTypes
45 |
46 |
47 | CFBundleTypeRole
48 | Editor
49 | CFBundleURLSchemes
50 |
51 | je-internal
52 | justeat
53 | just-eat
54 | just-eat-uk
55 |
56 |
57 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/Assets.xcassets/AppIcon.appiconset/Contents.json:
--------------------------------------------------------------------------------
1 | {
2 | "images" : [
3 | {
4 | "idiom" : "iphone",
5 | "size" : "20x20",
6 | "scale" : "2x"
7 | },
8 | {
9 | "idiom" : "iphone",
10 | "size" : "20x20",
11 | "scale" : "3x"
12 | },
13 | {
14 | "idiom" : "iphone",
15 | "size" : "29x29",
16 | "scale" : "2x"
17 | },
18 | {
19 | "idiom" : "iphone",
20 | "size" : "29x29",
21 | "scale" : "3x"
22 | },
23 | {
24 | "idiom" : "iphone",
25 | "size" : "40x40",
26 | "scale" : "2x"
27 | },
28 | {
29 | "idiom" : "iphone",
30 | "size" : "40x40",
31 | "scale" : "3x"
32 | },
33 | {
34 | "idiom" : "iphone",
35 | "size" : "60x60",
36 | "scale" : "2x"
37 | },
38 | {
39 | "idiom" : "iphone",
40 | "size" : "60x60",
41 | "scale" : "3x"
42 | },
43 | {
44 | "idiom" : "ipad",
45 | "size" : "20x20",
46 | "scale" : "1x"
47 | },
48 | {
49 | "idiom" : "ipad",
50 | "size" : "20x20",
51 | "scale" : "2x"
52 | },
53 | {
54 | "idiom" : "ipad",
55 | "size" : "29x29",
56 | "scale" : "1x"
57 | },
58 | {
59 | "idiom" : "ipad",
60 | "size" : "29x29",
61 | "scale" : "2x"
62 | },
63 | {
64 | "idiom" : "ipad",
65 | "size" : "40x40",
66 | "scale" : "1x"
67 | },
68 | {
69 | "idiom" : "ipad",
70 | "size" : "40x40",
71 | "scale" : "2x"
72 | },
73 | {
74 | "idiom" : "ipad",
75 | "size" : "76x76",
76 | "scale" : "1x"
77 | },
78 | {
79 | "idiom" : "ipad",
80 | "size" : "76x76",
81 | "scale" : "2x"
82 | },
83 | {
84 | "idiom" : "ipad",
85 | "size" : "83.5x83.5",
86 | "scale" : "2x"
87 | },
88 | {
89 | "idiom" : "ios-marketing",
90 | "size" : "1024x1024",
91 | "scale" : "1x"
92 | }
93 | ],
94 | "info" : {
95 | "version" : 1,
96 | "author" : "xcode"
97 | }
98 | }
--------------------------------------------------------------------------------
/NavigationEngineDemoTests/Navigation/FlowControllerProviderTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FlowControllerProviderTests.swift
3 | // NavigationEngine-Unit-Tests
4 | //
5 | // Created by Alberto De Bortoli on 11/02/2019.
6 | //
7 |
8 | import XCTest
9 | import Stateful
10 | @testable import NavigationEngineDemo
11 |
12 | class FlowControllerProviderTests: XCTestCase {
13 |
14 | private var flowControllerProvider: FlowControllerProvider!
15 | private let rootFlowController = MockRootFlowController()
16 |
17 | override func setUp() {
18 | super.setUp()
19 | rootFlowController.setup()
20 | flowControllerProvider = FlowControllerProvider(rootFlowController: rootFlowController)
21 | }
22 |
23 | func test_rootFlowController() {
24 | let fc1 = flowControllerProvider.rootFlowController as! MockRootFlowController
25 | let fc2 = rootFlowController
26 | XCTAssert(fc1 === fc2)
27 | }
28 |
29 | func test_restaurantsFlowController() {
30 | let fc1 = flowControllerProvider.restaurantsFlowController as! MockRestaurantsFlowController
31 | let fc2 = rootFlowController.restaurantsFlowController as! MockRestaurantsFlowController
32 | XCTAssert(fc1 === fc2)
33 | }
34 |
35 | func test_checkoutFlowController() {
36 | let fc1 = flowControllerProvider.checkoutFlowController as! MockCheckoutFlowController
37 | let fc2 = rootFlowController.restaurantsFlowController.checkoutFlowController as! MockCheckoutFlowController
38 | XCTAssert(fc1 === fc2)
39 | }
40 |
41 | func test_ordersFlowController() {
42 | let fc1 = flowControllerProvider.ordersFlowController as! MockOrdersFlowController
43 | let fc2 = rootFlowController.ordersFlowController as! MockOrdersFlowController
44 | XCTAssert(fc1 === fc2)
45 | }
46 |
47 | func test_settingsFlowController() {
48 | let fc1 = flowControllerProvider.settingsFlowController as! MockSettingsFlowController
49 | let fc2 = rootFlowController.settingsFlowController as! MockSettingsFlowController
50 | XCTAssert(fc1 === fc2)
51 | }
52 | }
53 |
--------------------------------------------------------------------------------
/NavigationEngineDemoTests/Parsing/SpotlightItemConverterTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // SpotlightItemConverterTests.swift
3 | // NavigationEngineTests
4 | //
5 | // Created by Alberto De Bortoli on 07/02/2019.
6 | //
7 |
8 | import XCTest
9 | import CoreSpotlight
10 | @testable import NavigationEngineDemo
11 |
12 | class SpotlightItemConverterTests: XCTestCase {
13 |
14 | private var spotlightItemConverter: SpotlightItemConverter!
15 | private var mockDeepLinkingSettings = MockDeepLinkingSettings()
16 |
17 | override func setUp() {
18 | super.setUp()
19 | spotlightItemConverter = SpotlightItemConverter(settings: mockDeepLinkingSettings)
20 | }
21 |
22 | func test_restaurantShortcutItem() {
23 | let target = DeepLink(string: "je-internal://je.com/restaurant?restaurantId=456")
24 | let spotlightItem = MockNSUserActivity(activityType: CSSearchableItemActionType, userInfo: [CSSearchableItemActivityIdentifier: "/restaurant/456"])
25 | let test = spotlightItemConverter.deepLink(forSpotlightItem: spotlightItem)
26 | XCTAssertEqual(target, test)
27 | }
28 |
29 | func test_orderDetailsShortcutItem() {
30 | let target = DeepLink(string: "je-internal://je.com/order?orderId=123")
31 | let spotlightItem = MockNSUserActivity(activityType: CSSearchableItemActionType, userInfo: [CSSearchableItemActivityIdentifier: "/orderDetails/123"])
32 | let test = spotlightItemConverter.deepLink(forSpotlightItem: spotlightItem)
33 | XCTAssertEqual(target, test)
34 | }
35 |
36 | func test_invalidShortcutItem() {
37 | var spotlightItem = MockNSUserActivity(activityType: CSSearchableItemActionType, userInfo: [CSSearchableItemActivityIdentifier: "/invalid"])
38 | var test = spotlightItemConverter.deepLink(forSpotlightItem: spotlightItem)
39 | XCTAssertNil(test)
40 |
41 | spotlightItem = MockNSUserActivity(activityType: CSSearchableItemActionType, userInfo: [CSSearchableItemActivityIdentifier: "invalid"])
42 | test = spotlightItemConverter.deepLink(forSpotlightItem: spotlightItem)
43 | XCTAssertNil(test)
44 |
45 | spotlightItem = MockNSUserActivity(activityType: CSSearchableItemActionType, userInfo: [CSSearchableItemActivityIdentifier: "/invalid/123"])
46 | test = spotlightItemConverter.deepLink(forSpotlightItem: spotlightItem)
47 | XCTAssertNil(test)
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/NavigationEngineIntegration/FlowControllers/OrdersFlowController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // OrdersFlowController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 23/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import Promis
11 |
12 | protocol OrdersFlowControllerDelegate: class {
13 | func ordersFlowController(_ flowController: OrdersFlowController, didRequestGoingToRestaurant restaurantId: String) -> Future
14 | }
15 |
16 | class OrdersFlowController: OrdersFlowControllerProtocol {
17 |
18 | weak var delegate: OrdersFlowControllerDelegate!
19 | private let navigationController: UINavigationController
20 | public let parentFlowController: RootFlowControllerProtocol!
21 |
22 | init(with parentFlowController: RootFlowControllerProtocol, navigationController: UINavigationController) {
23 | self.parentFlowController = parentFlowController
24 | self.navigationController = navigationController
25 | }
26 |
27 | @discardableResult
28 | func dismiss(animated: Bool) -> Future {
29 | let promise = Promise()
30 | navigationController.dismiss(animated: animated)
31 | // cannot use the completion to fulfill the future since it might never get called
32 | promise.setResult(true)
33 | return promise.future
34 | }
35 |
36 | @discardableResult
37 | func goBackToRoot(animated: Bool) -> Future {
38 | let promise = Promise()
39 | navigationController.popToRootViewController(animated: animated)
40 | promise.setResult(true)
41 | return promise.future
42 | }
43 |
44 | @discardableResult
45 | func goToOrder(orderId: OrderId, animated: Bool) -> Future {
46 | let promise = Promise()
47 | let orderDetails = OrderDetailsViewController.instantiate()
48 | orderDetails.flowController = self
49 | navigationController.pushViewController(orderDetails, animated: animated)
50 | promise.setResult(true)
51 | return promise.future
52 | }
53 |
54 | @discardableResult
55 | func goToRestaurant(restaurantId: String) -> Future {
56 | return delegate.ordersFlowController(self, didRequestGoingToRestaurant: restaurantId)
57 | }
58 |
59 | @objc func leaveFlow(_ sender: Any) {
60 | leaveFlow(animated: true)
61 | }
62 |
63 | @discardableResult
64 | func leaveFlow(animated: Bool) -> Future {
65 | let promise = Promise()
66 | navigationController.presentedViewController?.dismiss(animated: animated) {
67 | promise.setResult(true)
68 | }
69 | return promise.future
70 | }
71 | }
72 |
--------------------------------------------------------------------------------
/NavigationEngineDemoTests/Parsing/ShortcutItemConverterTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // ShortcutItemConverterTests.swift
3 | // NavigationEngineTests
4 | //
5 | // Created by Alberto De Bortoli on 07/02/2019.
6 | //
7 |
8 | import XCTest
9 | @testable import NavigationEngineDemo
10 |
11 | class ShortcutItemConverterTests: XCTestCase {
12 |
13 | private var shortcutItemConverter: ShortcutItemConverter!
14 | private var mockDeepLinkingSettings = MockDeepLinkingSettings()
15 |
16 | override func setUp() {
17 | super.setUp()
18 | shortcutItemConverter = ShortcutItemConverter(settings: mockDeepLinkingSettings)
19 | }
20 |
21 | func test_serpShortcutItem() {
22 | let target = DeepLink(string: "je-internal://je.com/search")
23 | let shortcutItem = UIApplicationShortcutItem(type: "/search", localizedTitle: "Search")
24 | let test = shortcutItemConverter.deepLink(forShortcutItem: shortcutItem)
25 | XCTAssertEqual(target, test)
26 | }
27 |
28 | func test_serpWithPostcodeShortcutItem() {
29 | let target = DeepLink(string: "je-internal://je.com/search?postcode=EC4M7RF")
30 | let shortcutItem = UIApplicationShortcutItem(type: "/search/EC4M7RF", localizedTitle: "Search")
31 | let test = shortcutItemConverter.deepLink(forShortcutItem: shortcutItem)
32 | XCTAssertEqual(target, test)
33 | }
34 |
35 | func test_orderHistoryShortcutItem() {
36 | let target = DeepLink(string: "je-internal://je.com/orders")
37 | let shortcutItem = UIApplicationShortcutItem(type: "/orderHistory", localizedTitle: "Orders")
38 | let test = shortcutItemConverter.deepLink(forShortcutItem: shortcutItem)
39 | XCTAssertEqual(target, test)
40 | }
41 |
42 | func test_reorderShortcutItem() {
43 | let target = DeepLink(string: "je-internal://je.com/reorder?orderId=123")
44 | let shortcutItem = UIApplicationShortcutItem(type: "/reorder/123", localizedTitle: "Reorder")
45 | let test = shortcutItemConverter.deepLink(forShortcutItem: shortcutItem)
46 | XCTAssertEqual(target, test)
47 | }
48 |
49 | func test_invalidShortcutItem() {
50 | var shortcutItem = UIApplicationShortcutItem(type: "/invalid", localizedTitle: "Invalid")
51 | var test = shortcutItemConverter.deepLink(forShortcutItem: shortcutItem)
52 | XCTAssertNil(test)
53 |
54 | shortcutItem = UIApplicationShortcutItem(type: "invalid", localizedTitle: "Invalid")
55 | test = shortcutItemConverter.deepLink(forShortcutItem: shortcutItem)
56 | XCTAssertNil(test)
57 |
58 | shortcutItem = UIApplicationShortcutItem(type: "/invalid/123", localizedTitle: "Invalid")
59 | test = shortcutItemConverter.deepLink(forShortcutItem: shortcutItem)
60 | XCTAssertNil(test)
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/NavigationEngineIntegration/FlowControllers/CheckoutFlowController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CheckoutFlowController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 23/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import Promis
11 |
12 | protocol CheckoutFlowControllerDelegate: class {
13 | func checkoutFlowControllerDidRequestGoingBackToSERP(_ flowController: CheckoutFlowController) -> Future
14 | func checkoutFlowController(_ flowController: CheckoutFlowController, didRequestGoingToOrder orderId: OrderId) -> Future
15 | }
16 |
17 | class CheckoutFlowController: CheckoutFlowControllerProtocol {
18 |
19 | weak var delegate: CheckoutFlowControllerDelegate!
20 | private let navigationController: UINavigationController
21 | public let parentFlowController: RestaurantsFlowControllerProtocol!
22 |
23 | init(with parentFlowController: RestaurantsFlowControllerProtocol, navigationController: UINavigationController) {
24 | self.parentFlowController = parentFlowController
25 | self.navigationController = navigationController
26 | }
27 |
28 | @discardableResult
29 | func beginFlow(from viewController: UIViewController, animated: Bool) -> Future {
30 | let promise = Promise()
31 | let checkoutVC = CheckoutViewController.instantiate()
32 | checkoutVC.flowController = self
33 | navigationController.viewControllers = [checkoutVC]
34 |
35 | let leaveButton = UIBarButtonItem(title: "Leave", style: .plain, target: self, action: #selector(leaveFlow(_:)))
36 | checkoutVC.navigationItem.rightBarButtonItem = leaveButton
37 |
38 | viewController.present(navigationController, animated: animated) {
39 | promise.setResult(true)
40 | }
41 | return promise.future
42 | }
43 |
44 | @discardableResult
45 | func proceedToPayment(animated: Bool) -> Future {
46 | let paymentVC = PaymentViewController.instantiate()
47 | paymentVC.flowController = self
48 | navigationController.pushViewController(paymentVC, animated: animated)
49 | return Future.future(withResult: true)
50 | }
51 |
52 | @discardableResult
53 | func goToOrderConfirmation(orderId: OrderId) -> Future {
54 | return leaveFlow(animated: false).then { future in
55 | self.delegate.checkoutFlowController(self, didRequestGoingToOrder: orderId)
56 | }
57 | }
58 |
59 | @objc func leaveFlow(_ sender: Any) {
60 | leaveFlow(animated: true)
61 | }
62 |
63 | @discardableResult
64 | func leaveFlow(animated: Bool) -> Future {
65 | let promise = Promise()
66 | navigationController.presentingViewController?.dismiss(animated: animated) {
67 | promise.setResult(true)
68 | }
69 | return promise.future
70 | }
71 |
72 | @discardableResult
73 | func leaveFlowAndGoBackToSERP() -> Future {
74 | return leaveFlow(animated: false).then { future in
75 | self.delegate.checkoutFlowControllerDidRequestGoingBackToSERP(self)
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Parsing/URLGateway.swift:
--------------------------------------------------------------------------------
1 | //
2 | // URLGateway.swift
3 | // Just Eat
4 | //
5 | // Created by Alberto De Bortoli on 27/04/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import Promis
11 |
12 | class URLGateway {
13 |
14 | static let domain = "com.justeat.URLGateway"
15 |
16 | enum ErrorCode: Int {
17 | case missingURLScheme
18 | case unsupportedUniversalLink
19 | case generic
20 | }
21 |
22 | private let universalLinkConverter: UniversalLinkConverter
23 | private let settings: DeepLinkingSettingsProtocol
24 |
25 | init(settings: DeepLinkingSettingsProtocol) {
26 | self.settings = settings
27 | self.universalLinkConverter = UniversalLinkConverter(settings: settings)
28 | }
29 |
30 | func handleURL(_ url: URL) -> Future {
31 | let promise = Promise()
32 |
33 | guard let scheme = url.scheme?.lowercased() else {
34 | let error = NSError(domain: URLGateway.domain, code: ErrorCode.missingURLScheme.rawValue, userInfo: nil)
35 | promise.setError(error)
36 | return promise.future
37 | }
38 |
39 | // 1. check if universal link
40 | if settings.universalLinkSchemes.contains(scheme) {
41 | self.resultForUniversalLink(url).finally { future in
42 | promise.setResolution(of: future)
43 | }
44 | }
45 |
46 | // 2. check if deep link
47 | else if settings.internalDeepLinkSchemes.contains(scheme) {
48 | promise.setResult(url)
49 | }
50 |
51 | // 3. generic error
52 | else {
53 | let genericError = NSError(domain: URLGateway.domain, code: ErrorCode.generic.rawValue, userInfo: nil)
54 | promise.setError(genericError)
55 | }
56 |
57 | return promise.future
58 | }
59 |
60 | private func resultForUniversalLink(_ url: UniversalLink) -> Future {
61 | let promise = Promise()
62 | if let deepLink = universalLinkConverter.deepLink(forUniversalLink: url) {
63 | promise.setResult(deepLink)
64 | }
65 | else {
66 | let genericError = NSError(domain: URLGateway.domain, code: ErrorCode.unsupportedUniversalLink.rawValue, userInfo: nil)
67 | promise.setError(genericError)
68 | }
69 | return promise.future
70 | }
71 | }
72 |
73 | extension URL {
74 |
75 | func contains(subparts: [String]) -> Bool {
76 | return subparts.filter { absoluteString.contains($0) }.count > 0
77 | }
78 |
79 | var allQueryItems: [NSURLQueryItem] {
80 | let components = NSURLComponents(url: self as URL, resolvingAgainstBaseURL: false)!
81 | guard let allQueryItems = components.queryItems
82 | else { return [] }
83 | return allQueryItems as [NSURLQueryItem]
84 | }
85 |
86 | func queryItemValueFor(key: String) -> String? {
87 | let predicate = NSPredicate(format: "name=%@", key)
88 | let queryItem = (allQueryItems as NSArray).filtered(using: predicate).first as? NSURLQueryItem
89 | return queryItem?.value
90 | }
91 | }
92 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/NavigationEngineIntegration/FlowControllers/AccountFlowController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // AccountFlowController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 05/02/2019.
6 | // Copyright © 2019 Just Eat. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import Promis
11 |
12 | class AccountFlowController: AccountFlowControllerProtocol {
13 |
14 | private let navigationController: UINavigationController
15 | public let parentFlowController: RootFlowControllerProtocol!
16 |
17 | init(with parentFlowController: RootFlowControllerProtocol, navigationController: UINavigationController) {
18 | self.parentFlowController = parentFlowController
19 | self.navigationController = navigationController
20 | }
21 |
22 | @discardableResult
23 | func beginLoginFlow(from viewController: UIViewController, animated: Bool) -> Future {
24 | let promise = Promise()
25 | let loginVC = LoginViewController.instantiate()
26 | loginVC.flowController = self
27 | navigationController.viewControllers = [loginVC]
28 |
29 | let leaveButton = UIBarButtonItem(title: "Leave", style: .plain, target: self, action: #selector(leaveFlow(_:)))
30 | loginVC.navigationItem.rightBarButtonItem = leaveButton
31 |
32 | viewController.present(navigationController, animated: animated)
33 |
34 | promise.setResult(true)
35 | return promise.future
36 | }
37 |
38 | @discardableResult
39 | func waitForUserToLogin(from viewController: UIViewController, animated: Bool) -> Future {
40 | let promise = Promise()
41 | let loginVC = LoginViewController.instantiate()
42 | loginVC.flowController = self
43 | navigationController.viewControllers = [loginVC]
44 |
45 | let leaveButton = UIBarButtonItem(title: "Leave", style: .plain, target: self, action: #selector(leaveFlow(_:)))
46 | loginVC.navigationItem.rightBarButtonItem = leaveButton
47 |
48 | viewController.present(navigationController, animated: animated) {
49 | promise.setResult(true)
50 | }
51 | return promise.future
52 | }
53 |
54 | @discardableResult
55 | func beginResetPasswordFlow(from viewController: UIViewController, token: ResetPasswordToken, animated: Bool) -> Future {
56 | let promise = Promise()
57 | let resetPswVC = ResetPasswordViewController.instantiate()
58 | resetPswVC.flowController = self
59 | resetPswVC.token = token
60 | navigationController.viewControllers = [resetPswVC]
61 |
62 | let leaveButton = UIBarButtonItem(title: "Leave", style: .plain, target: self, action: #selector(leaveFlow(_:)))
63 | resetPswVC.navigationItem.rightBarButtonItem = leaveButton
64 |
65 | viewController.present(navigationController, animated: animated) {
66 | promise.setResult(true)
67 | }
68 | return promise.future
69 | }
70 |
71 | @objc func leaveFlow(_ sender: Any) {
72 | leaveFlow(animated: true)
73 | }
74 |
75 | @discardableResult
76 | func leaveFlow(animated: Bool) -> Future {
77 | let promise = Promise()
78 | navigationController.presentingViewController?.dismiss(animated: animated) {
79 | promise.setResult(true)
80 | }
81 | return promise.future
82 | }
83 | }
84 |
--------------------------------------------------------------------------------
/NavigationEngineDemoTests/Parsing/URLGatewayTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // URLGatewayTests.swift
3 | // NavigationEngineTests
4 | //
5 | // Created by Alberto De Bortoli on 07/02/2019.
6 | //
7 |
8 | import XCTest
9 | @testable import NavigationEngineDemo
10 |
11 | class URLGatewayTests: XCTestCase {
12 |
13 | private let timeout = 3.0
14 | private var urlGateway: URLGateway!
15 |
16 | func test_universalLink() {
17 | let expectation = self.expectation(description: #function)
18 | urlGateway = URLGateway(settings: MockDeepLinkingSettings())
19 | let url = URL(string: "https://just-eat.co.uk/login")!
20 | urlGateway.handleURL(url).finally { future in
21 | XCTAssertTrue(future.hasResult())
22 | let target = DeepLink(string: "je-internal://je.com/login")
23 | let test = future.result!
24 | XCTAssertEqual(test, target)
25 | expectation.fulfill()
26 | }
27 | wait(for: [expectation], timeout: timeout)
28 | }
29 |
30 | func test_universalLink_invalid() {
31 | let expectation = self.expectation(description: #function)
32 | urlGateway = URLGateway(settings: MockDeepLinkingSettings())
33 | let url = URL(string: "https://just-eat.co.uk/invalid")!
34 | urlGateway.handleURL(url).finally { future in
35 | XCTAssertTrue(future.hasError())
36 | let error = future.error! as NSError
37 | XCTAssertEqual(error.domain, URLGateway.domain)
38 | XCTAssertEqual(error.code, URLGateway.ErrorCode.unsupportedUniversalLink.rawValue)
39 | expectation.fulfill()
40 | }
41 | wait(for: [expectation], timeout: timeout)
42 | }
43 |
44 | func test_deepLink() {
45 | let expectation = self.expectation(description: #function)
46 | urlGateway = URLGateway(settings: MockDeepLinkingSettings())
47 | let url = DeepLink(string: "je-internal://je.com/login")!
48 | urlGateway.handleURL(url).finally { future in
49 | XCTAssertTrue(future.hasResult())
50 | let target = DeepLink(string: "je-internal://je.com/login")
51 | let test = future.result!
52 | XCTAssertEqual(test, target)
53 | expectation.fulfill()
54 | }
55 | wait(for: [expectation], timeout: timeout)
56 | }
57 |
58 | func test_deepLinkInvalid() {
59 | let expectation = self.expectation(description: #function)
60 | urlGateway = URLGateway(settings: MockDeepLinkingSettings())
61 | let url = DeepLink(string: "je-internal://je.com/invalid")!
62 | urlGateway.handleURL(url).finally { future in
63 | XCTAssertTrue(future.hasResult())
64 | let target = DeepLink(string: "je-internal://je.com/invalid")
65 | let test = future.result!
66 | XCTAssertEqual(test, target)
67 | expectation.fulfill()
68 | }
69 | wait(for: [expectation], timeout: timeout)
70 | }
71 |
72 | func test_invalidLink() {
73 | let expectation = self.expectation(description: #function)
74 | urlGateway = URLGateway(settings: MockDeepLinkingSettings())
75 | let url = URL(string: "someotherscheme://je.com/login")!
76 | urlGateway.handleURL(url).finally { future in
77 | XCTAssertTrue(future.hasError())
78 | let error = future.error! as NSError
79 | XCTAssertEqual(error.domain, URLGateway.domain)
80 | XCTAssertEqual(error.code, URLGateway.ErrorCode.generic.rawValue)
81 | expectation.fulfill()
82 | }
83 | wait(for: [expectation], timeout: timeout)
84 | }
85 | }
86 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/NavigationEngineIntegration/FlowControllers/RestaurantsFlowController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RestaurantsFlowController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 23/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import Promis
11 |
12 | protocol RestaurantsFlowControllerDelegate: class {
13 | func restaurantsFlowController(_ flowController: RestaurantsFlowController, didRequestGoingToOrder orderId: OrderId) -> Future
14 | }
15 |
16 | class RestaurantsFlowController: RestaurantsFlowControllerProtocol {
17 |
18 | weak var delegate: RestaurantsFlowControllerDelegate!
19 | fileprivate let navigationController: UINavigationController
20 | let parentFlowController: RootFlowControllerProtocol!
21 |
22 | var checkoutFlowController: CheckoutFlowControllerProtocol!
23 |
24 | init(with parentFlowController: RootFlowControllerProtocol, navigationController: UINavigationController) {
25 | self.parentFlowController = parentFlowController
26 | self.navigationController = navigationController
27 | }
28 |
29 | @discardableResult
30 | func dismiss(animated: Bool) -> Future {
31 | let promise = Promise()
32 | navigationController.dismiss(animated: animated)
33 | // cannot use the completion to fulfill the future since it might never get called
34 | promise.setResult(true)
35 | return promise.future
36 | }
37 |
38 | @discardableResult
39 | func goBackToRoot(animated: Bool) -> Future {
40 | let promise = Promise()
41 | navigationController.popToRootViewController(animated: animated)
42 | promise.setResult(true)
43 | return promise.future
44 | }
45 |
46 | @discardableResult
47 | func goToSearchAnimated(postcode: Postcode?, cuisine: Cuisine?, animated: Bool) -> Future {
48 | let promise = Promise()
49 | let serp = SerpViewController.instantiate()
50 | serp.flowController = self
51 | navigationController.pushViewController(serp, animated: animated)
52 | promise.setResult(true)
53 | return promise.future
54 | }
55 |
56 | @discardableResult
57 | func goToRestaurant(restaurantId: RestaurantId, postcode: Postcode?, serviceType: ServiceType?, section: RestaurantSection?, animated: Bool) -> Future {
58 | let promise = Promise()
59 | let restaurantVC = RestaurantViewController.instantiate()
60 | restaurantVC.flowController = self
61 | navigationController.pushViewController(restaurantVC, animated: animated)
62 | promise.setResult(true)
63 | return promise.future
64 | }
65 |
66 | @discardableResult
67 | func goToBasket(reorderId: ReorderId, animated: Bool) -> Future {
68 | let promise = Promise()
69 | let basket = BasketViewController.instantiate()
70 | basket.flowController = self
71 | navigationController.pushViewController(basket, animated: animated)
72 | promise.setResult(true)
73 | return promise.future
74 | }
75 |
76 | @discardableResult
77 | func goToCheckout(animated: Bool) -> Future {
78 | let checkoutNC = UINavigationController()
79 | let flowController = CheckoutFlowController(with: self, navigationController: checkoutNC)
80 | flowController.delegate = self
81 | checkoutFlowController = flowController
82 | return checkoutFlowController.beginFlow(from: navigationController, animated: animated)
83 | }
84 | }
85 |
86 | extension RestaurantsFlowController: CheckoutFlowControllerDelegate {
87 |
88 | @discardableResult
89 | func checkoutFlowControllerDidRequestGoingBackToSERP(_ flowController: CheckoutFlowController) -> Future {
90 | navigationController.viewControllers = Array(navigationController.viewControllers[0...1])
91 | return Future.future(withResult: true)
92 | }
93 |
94 | @discardableResult
95 | func checkoutFlowController(_ flowController: CheckoutFlowController, didRequestGoingToOrder orderId: OrderId) -> Future {
96 | return delegate.restaurantsFlowController(self, didRequestGoingToOrder: orderId)
97 | }
98 | }
99 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/UserInputController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // UserInputController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 01/02/2019.
6 | // Copyright © 2019 Just Eat. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import Promis
11 |
12 | class UserInputController {
13 |
14 | private let domain = "com.justeat.userInputController"
15 |
16 | private enum ErrorCode: Int {
17 | case abortUserInput
18 | case abortFakeLogin
19 | }
20 |
21 | private let userStatusProvider: UserStatusProvider
22 |
23 | init(userStatusProvider: UserStatusProvider) {
24 | self.userStatusProvider = userStatusProvider
25 | }
26 |
27 | func gatherReorderUserInput() -> Future {
28 | let promise = Promise()
29 |
30 | showReorderUserInputAlert { input in
31 | if let input = input {
32 | promise.setResult(input)
33 | }
34 | else {
35 | let error = NSError(domain: self.domain, code: ErrorCode.abortUserInput.rawValue, userInfo: nil)
36 | promise.setError(error)
37 | }
38 | }
39 |
40 | return promise.future
41 | }
42 |
43 | func promptUserForLogin() -> Future {
44 | let promise = Promise()
45 |
46 | showLoginUserAlert { success in
47 | if success {
48 | self.userStatusProvider.userStatus = .loggedIn
49 | promise.setResult(true)
50 | }
51 | else {
52 | let error = NSError(domain: self.domain, code: ErrorCode.abortFakeLogin.rawValue, userInfo: nil)
53 | promise.setError(error)
54 | }
55 | }
56 |
57 | return promise.future
58 | }
59 |
60 | private func showReorderUserInputAlert(handler: @escaping (ReorderInfo?) -> Void) {
61 | let alert = UIAlertController(title: "Reorder user input", message: "Please enter the reorder required values", preferredStyle: .alert)
62 | let action = UIAlertAction(title: "Confirm", style: .default) { _ in
63 | let postcode = (alert.textFields![0] as UITextField).text ?? ""
64 | let restaurantId = (alert.textFields![1] as UITextField).text ?? ""
65 | let serviceType = ServiceType(rawValue: (alert.textFields![2] as UITextField).text ?? "") ?? ServiceType.delivery
66 | let reorderInfo = ReorderInfo(restaurantId: restaurantId, postcode: postcode, serviceType: serviceType)
67 | handler(reorderInfo)
68 | }
69 | let action2 = UIAlertAction(title: "Cancel", style: .cancel) { _ in
70 | handler(nil)
71 | }
72 | alert.addAction(action)
73 | alert.addAction(action2)
74 |
75 | alert.addTextField { textField in
76 | textField.placeholder = "postcode"
77 | }
78 | alert.addTextField { textField in
79 | textField.placeholder = "seo name"
80 | }
81 | alert.addTextField { textField in
82 | textField.placeholder = "service type"
83 | }
84 |
85 | presentAlert(alert)
86 | }
87 |
88 | private func showLoginUserAlert(handler: @escaping (Bool) -> Void) {
89 | let alert = UIAlertController(title: "Fake login", message: "Proceed to faking a user login", preferredStyle: .alert)
90 | let action = UIAlertAction(title: "Yes", style: .default) { _ in
91 | handler(true)
92 | }
93 | let action2 = UIAlertAction(title: "No", style: .cancel) { _ in
94 | handler(false)
95 | }
96 | alert.addAction(action)
97 | alert.addAction(action2)
98 | presentAlert(alert)
99 | }
100 |
101 | private func presentAlert(_ alert: UIAlertController) {
102 | let presentingViewController = UIApplication.shared.delegate!.window!!.rootViewController!
103 | if let presentedViewController = presentingViewController.presentedViewController {
104 | presentedViewController.present(alert, animated: true) { }
105 | }
106 | else {
107 | presentingViewController.present(alert, animated: true) { }
108 | }
109 | }
110 | }
111 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Tester/DeepLinkingTesterViewController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DeepLinkingTesterViewController.swift
3 | // JUSTEAT
4 | //
5 | // Created by Shabeer Hussain on 22/11/2018.
6 | // Copyright © 2018 JUST EAT. All rights reserved.
7 | //
8 |
9 | import UIKit
10 |
11 | public protocol DeepLinkingTesterViewControllerDelegate: class {
12 | func deepLinkingTesterViewController(_ deepLinkingTesterViewController: DeepLinkingTesterViewController, didSelect url: URL)
13 | }
14 |
15 | public class DeepLinkingTesterViewController: UIViewController {
16 |
17 | @IBOutlet weak var descriptionLabel: UILabel!
18 | @IBOutlet weak var quickLinksLabel: UILabel!
19 | @IBOutlet weak var urlTextField: UITextField!
20 | @IBOutlet weak var actionButton: UIButton!
21 | @IBOutlet weak var tableView: UITableView!
22 | @IBOutlet weak var linkTypeSelector: UISegmentedControl!
23 |
24 | public weak var delegate: DeepLinkingTesterViewControllerDelegate?
25 |
26 | public func loadTestLinks(atPath filePath: String) {
27 | let url = URL(fileURLWithPath: filePath)
28 | let fileContent = try! Data(contentsOf: url)
29 | let jsonObject = try! JSONSerialization.jsonObject(with: fileContent, options: []) as! [String: Any]
30 | universalLinks = jsonObject["universal_links"] as? [String]
31 | deepLinks = jsonObject["deep_links"] as? [String]
32 | }
33 |
34 | private var currentLinks: [String]!
35 | private var universalLinks: [String]!
36 | private var deepLinks: [String]!
37 |
38 | public static func instantiate() -> DeepLinkingTesterViewController {
39 | let fullName = NSStringFromClass(self)
40 | let className = fullName.components(separatedBy: ".")[1]
41 | let storyboard = UIStoryboard(name: "DeepLinkingTester", bundle: Bundle(for: self))
42 | return storyboard.instantiateViewController(withIdentifier: className) as! DeepLinkingTesterViewController
43 | }
44 |
45 | override public func viewDidLoad() {
46 | super.viewDidLoad()
47 | setupTableView()
48 | applyLocalisation()
49 | currentLinks = universalLinks
50 | }
51 |
52 | func setupTableView() {
53 | tableView.delegate = self
54 | tableView.dataSource = self
55 | }
56 |
57 | func applyLocalisation() {
58 | title = "DeepLinking Tester"
59 | urlTextField.placeholder = "https://yourdomain.com/"
60 | descriptionLabel.text = "Enter a URL and test how the app handles it"
61 | }
62 |
63 | private func sanitisedUniversalLink(from universalLink: String?) -> URL? {
64 | guard
65 | let text = universalLink,
66 | text.count > 0,
67 | let url = URL(string: text)
68 | else { return nil }
69 |
70 | return url
71 | }
72 | }
73 |
74 | extension DeepLinkingTesterViewController {
75 |
76 | @IBAction func buttonTapped(_ sender: UIButton) {
77 | guard let url = sanitisedUniversalLink(from: urlTextField.text) else { return }
78 | delegate?.deepLinkingTesterViewController(self, didSelect: url)
79 | }
80 |
81 | @IBAction public func segmentedControlDidSelect(sender: UISegmentedControl) {
82 | let selectedIndex = linkTypeSelector.selectedSegmentIndex
83 | currentLinks = (selectedIndex == 0 ? universalLinks : deepLinks)
84 | urlTextField.placeholder = (selectedIndex == 0 ? "https://yourdomain.com/" : "your-scheme://")
85 | tableView.reloadData()
86 | }
87 | }
88 |
89 | extension DeepLinkingTesterViewController: UITableViewDataSource, UITableViewDelegate {
90 |
91 | public func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
92 | let quickLink = currentLinks[indexPath.row]
93 | let cell = tableView.dequeueReusableCell(withIdentifier: "CellIdentifier", for: indexPath)
94 | cell.textLabel?.text = quickLink
95 | cell.selectionStyle = .none
96 | return cell
97 | }
98 |
99 | public func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int {
100 | return currentLinks.count
101 | }
102 |
103 | public func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) {
104 | urlTextField.text = currentLinks[indexPath.row]
105 | }
106 | }
107 |
--------------------------------------------------------------------------------
/NavigationEngineDemo.xcodeproj/xcshareddata/xcschemes/NavigationEngineDemo.xcscheme:
--------------------------------------------------------------------------------
1 |
2 |
5 |
8 |
9 |
15 |
21 |
22 |
23 |
24 |
25 |
31 |
32 |
34 |
40 |
41 |
42 |
43 |
44 |
50 |
51 |
52 |
53 |
54 |
55 |
65 |
67 |
73 |
74 |
75 |
76 |
77 |
78 |
84 |
86 |
92 |
93 |
94 |
95 |
97 |
98 |
101 |
102 |
103 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/DeepLinkingFacade.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DeepLinkingFacade.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 04/02/2019.
6 | // Copyright © 2019 Just Eat. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import UIKit
11 | import Promis
12 |
13 | public class DeepLinkingFacade {
14 |
15 | static let domain = "com.justeat.deepLinkingFacade"
16 |
17 | enum ErrorCode: Int {
18 | case couldNotHandleURL
19 | case couldNotHandleDeepLink
20 | case couldNotDeepLinkFromShortcutItem
21 | case couldNotDeepLinkFromSpotlightItem
22 | }
23 |
24 | private let flowControllerProvider: FlowControllerProvider
25 | private let navigationIntentHandler: NavigationIntentHandler
26 | private let urlGateway: URLGateway
27 | private let settings: DeepLinkingSettingsProtocol
28 | private let userStatusProvider: UserStatusProviding
29 |
30 | public init(flowControllerProvider: FlowControllerProvider,
31 | navigationTransitionerDataSource: NavigationTransitionerDataSource,
32 | settings: DeepLinkingSettingsProtocol,
33 | userStatusProvider: UserStatusProviding) {
34 | self.flowControllerProvider = flowControllerProvider
35 | self.navigationIntentHandler = NavigationIntentHandler(flowControllerProvider: flowControllerProvider,
36 | userStatusProvider: userStatusProvider,
37 | navigationTransitionerDataSource: navigationTransitionerDataSource)
38 | self.settings = settings
39 | self.userStatusProvider = userStatusProvider
40 | self.urlGateway = URLGateway(settings: settings)
41 | }
42 |
43 | @discardableResult
44 | public func handleURL(_ url: URL) -> Future {
45 | let promise = Promise()
46 | urlGateway.handleURL(url).finally { future in
47 | switch future.state {
48 | case .result(let deepLink):
49 | self.openDeepLink(deepLink).finally { future in
50 | promise.setResolution(of: future)
51 | }
52 |
53 | case .error(let error):
54 | let wrappedError = NSError(domain: DeepLinkingFacade.domain, code: ErrorCode.couldNotHandleURL.rawValue, userInfo: [NSUnderlyingErrorKey: error])
55 | promise.setError(wrappedError)
56 |
57 | default:
58 | let error = NSError(domain: DeepLinkingFacade.domain, code: ErrorCode.couldNotHandleURL.rawValue, userInfo: nil)
59 | promise.setError(error)
60 | }
61 | }
62 | return promise.future
63 | }
64 |
65 | @discardableResult
66 | public func openDeepLink(_ deepLink: DeepLink) -> Future {
67 | let result = NavigationIntentFactory().intent(for: deepLink)
68 | switch result {
69 | case .result(let intent):
70 | return self.navigationIntentHandler.handleIntent(intent)
71 | case .error(let error):
72 | let wrappedError = NSError(domain: DeepLinkingFacade.domain, code: ErrorCode.couldNotHandleDeepLink.rawValue, userInfo: [NSUnderlyingErrorKey: error])
73 | return Future.future(withError: wrappedError)
74 | }
75 | }
76 |
77 | @discardableResult
78 | public func openShortcutItem(_ item: UIApplicationShortcutItem) -> Future {
79 | let shortcutItemConverter = ShortcutItemConverter(settings: settings)
80 | if let deepLink = shortcutItemConverter.deepLink(forShortcutItem: item) {
81 | return openDeepLink(deepLink)
82 | }
83 | else {
84 | let error = NSError(domain: DeepLinkingFacade.domain, code: ErrorCode.couldNotDeepLinkFromShortcutItem.rawValue, userInfo: nil)
85 | return Future.future(withError: error)
86 | }
87 | }
88 |
89 | @discardableResult
90 | public func openSpotlightItem(_ userActivity: NSUserActivityProtocol) -> Future {
91 | let spotlightItemConverter = SpotlightItemConverter(settings: settings)
92 | if let deepLink = spotlightItemConverter.deepLink(forSpotlightItem: userActivity) {
93 | return openDeepLink(deepLink)
94 | }
95 | else {
96 | let error = NSError(domain: DeepLinkingFacade.domain, code: ErrorCode.couldNotDeepLinkFromSpotlightItem.rawValue, userInfo: nil)
97 | return Future.future(withError: error)
98 | }
99 | }
100 | }
101 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Parsing/DeepLinkFactory.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DeepLinkFactory.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 04/02/2019.
6 | // Copyright © 2019 Just Eat. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import CoreLocation
11 |
12 | public class DeepLinkFactory {
13 |
14 | let scheme: String
15 | let host: String
16 |
17 | public init(scheme: String, host: String) {
18 | self.scheme = scheme
19 | self.host = host
20 | }
21 | }
22 |
23 | extension DeepLinkFactory {
24 |
25 | public func homeURL() -> DeepLink {
26 | let endpoint = Endpoint(scheme: scheme,
27 | host: host,
28 | path: "/home",
29 | queryItems: nil)
30 | return endpoint.url
31 | }
32 |
33 | public func loginURL() -> DeepLink {
34 | let endpoint = Endpoint(scheme: scheme,
35 | host: host,
36 | path: "/login",
37 | queryItems: nil)
38 | return endpoint.url
39 | }
40 |
41 | public func updatePasswordURL(token: ResetPasswordToken) -> DeepLink {
42 | let tokenQueryItem = URLQueryItem(name: "resetToken", value: token)
43 | let endpoint = Endpoint(scheme: scheme,
44 | host: host,
45 | path: "/resetPassword",
46 | queryItems: [tokenQueryItem])
47 | return endpoint.url
48 | }
49 | }
50 |
51 | extension DeepLinkFactory {
52 |
53 | public func searchURL(postcode: Postcode?, cuisine: Cuisine?, location: CLLocationCoordinate2D?) -> DeepLink {
54 | let postcodeQueryItem = postcode != nil ? URLQueryItem(name: "postcode", value: postcode) : nil
55 | let cuisineQueryItem = cuisine != nil ? URLQueryItem(name: "cuisine", value: cuisine) : nil
56 | var queryItems = [postcodeQueryItem, cuisineQueryItem].compactMap { return $0 }
57 | if let location = location {
58 | let latitudeQueryItem = URLQueryItem(name: "lat", value: "\(location.latitude)")
59 | let longitudeQueryItem = URLQueryItem(name: "long", value: "\(location.longitude)")
60 | queryItems.append(latitudeQueryItem)
61 | queryItems.append(longitudeQueryItem)
62 | }
63 | let endpoint = Endpoint(scheme: scheme,
64 | host: host,
65 | path: "/search",
66 | queryItems: queryItems.count > 0 ? queryItems : nil)
67 | return endpoint.url
68 | }
69 |
70 | public func restaurantURL(restaurantId: RestaurantId, postcode: Postcode?, serviceType: ServiceType?, section: RestaurantSection?) -> DeepLink {
71 | let restaurantIdQueryItem = URLQueryItem(name: "restaurantId", value: restaurantId)
72 | let postcodeQueryItem = postcode != nil ? URLQueryItem(name: "postcode", value: postcode) : nil
73 | let serviceTypeQueryItem = serviceType != nil ? URLQueryItem(name: "serviceType", value: serviceType?.rawValue) : nil
74 | let sectionQueryItem = section != nil ? URLQueryItem(name: "section", value: section?.rawValue) : nil
75 | let queryItems = [restaurantIdQueryItem, postcodeQueryItem, serviceTypeQueryItem, sectionQueryItem].compactMap { return $0 }
76 | let endpoint = Endpoint(scheme: scheme,
77 | host: host,
78 | path: "/restaurant",
79 | queryItems: queryItems.count > 0 ? queryItems : nil)
80 | return endpoint.url
81 | }
82 | }
83 |
84 | extension DeepLinkFactory {
85 |
86 | public func orderHistoryURL() -> DeepLink {
87 | let endpoint = Endpoint(scheme: scheme,
88 | host: host,
89 | path: "/orders",
90 | queryItems: nil)
91 | return endpoint.url
92 | }
93 |
94 | public func orderDetailsURL(orderId: OrderId) -> DeepLink {
95 | let orderIdQueryItem = URLQueryItem(name: "orderId", value: orderId)
96 | let endpoint = Endpoint(scheme: scheme,
97 | host: host,
98 | path: "/order",
99 | queryItems: [orderIdQueryItem])
100 | return endpoint.url
101 | }
102 |
103 | public func reorderURL(orderId: OrderId) -> DeepLink {
104 | let orderIdQueryItem = URLQueryItem(name: "orderId", value: orderId)
105 | let endpoint = Endpoint(scheme: scheme,
106 | host: host,
107 | path: "/reorder",
108 | queryItems: [orderIdQueryItem])
109 | return endpoint.url
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Navigation/NavigationIntentFactory.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NavigationIntentFactory.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 28/12/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import Foundation
10 |
11 | public typealias OrderId = String
12 | public typealias Postcode = String
13 | public typealias Cuisine = String
14 | public typealias ReorderId = String
15 | public typealias ResetPasswordToken = String
16 | public typealias RestaurantId = String
17 |
18 | public enum ServiceType: String {
19 | case delivery
20 | case collection
21 | }
22 |
23 | public enum NavigationIntent: Equatable {
24 | case goToHome
25 | case goToLogin
26 | case goToResetPassword(ResetPasswordToken)
27 | case goToSearch(Postcode?, Cuisine?)
28 | case goToRestaurant(RestaurantId, Postcode?, ServiceType?, RestaurantSection?)
29 | case goToReorder(ReorderId)
30 | case goToOrderHistory
31 | case goToOrderDetails(OrderId)
32 | case goToSettings
33 | }
34 |
35 | enum NavigationIntentFactoryResult {
36 | case result(NavigationIntent)
37 | case error(Error)
38 | }
39 |
40 | class NavigationIntentFactory {
41 |
42 | static let domain = "com.justeat.navigationIntentFactory"
43 |
44 | enum ErrorCode: Int {
45 | case malformedURL
46 | case unsupportedURL
47 | }
48 |
49 | func intent(for url: DeepLink) -> NavigationIntentFactoryResult {
50 | guard let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
51 | let pathComponents = components.url?.pathComponents
52 | else {
53 | return .error(NSError(domain: NavigationIntentFactory.domain, code: ErrorCode.malformedURL.rawValue, userInfo: nil))
54 | }
55 |
56 | let queryItems = components.queryItems
57 |
58 | switch pathComponents.count {
59 | case 3:
60 | switch (pathComponents[1], pathComponents[2]) {
61 | case ("account", "login"):
62 | return .result(.goToLogin)
63 | case ("order", let orderId):
64 | return .result(.goToOrderDetails(orderId))
65 | case ("reorder", let orderId):
66 | return .result(.goToReorder(orderId))
67 | default: break
68 | }
69 |
70 | case 2:
71 | switch pathComponents[1] {
72 | case "home":
73 | return .result(.goToHome)
74 | case "login":
75 | return .result(.goToLogin)
76 | case "search":
77 | let postcode = queryValue(queryItems, key: "postcode")
78 | let cuisine = queryValue(queryItems, key: "cuisine")
79 | return .result(.goToSearch(postcode, cuisine))
80 | case ("resetPassword") where queryContainsKey(queryItems, key: "resetToken"):
81 | return .result(.goToResetPassword(queryValue(queryItems, key: "resetToken")!))
82 | case ("restaurant") where queryContainsKey(queryItems, key: "restaurantId"):
83 | let restaurantId = queryValue(queryItems, key: "restaurantId")!
84 | let postcode = queryValue(queryItems, key: "postcode")
85 | let serviceType: ServiceType? = {
86 | guard let val = queryValue(queryItems, key: "serviceType") else { return nil }
87 | return ServiceType(rawValue: val)
88 | }()
89 | let section: RestaurantSection? = {
90 | guard let val = queryValue(queryItems, key: "section") else { return nil }
91 | return RestaurantSection(rawValue: val)
92 | }()
93 | return .result(.goToRestaurant(restaurantId, postcode, serviceType, section))
94 | case ("orderHistory"):
95 | return .result(.goToOrderHistory)
96 | case ("order-history"):
97 | return .result(.goToOrderHistory)
98 | case ("orders"):
99 | return .result(.goToOrderHistory)
100 | case ("order") where queryContainsKey(queryItems, key: "orderId"):
101 | return .result(.goToOrderDetails(queryValue(queryItems, key: "orderId")!))
102 | case ("reorder") where queryContainsKey(queryItems, key: "orderId"):
103 | return .result(.goToReorder(queryValue(queryItems, key: "orderId")!))
104 | case ("settings"):
105 | return .result(.goToSettings)
106 | default: break
107 | }
108 |
109 | case 1:
110 | return .result(.goToHome)
111 |
112 | default: break
113 | }
114 |
115 | return .error(NSError(domain: NavigationIntentFactory.domain, code: ErrorCode.unsupportedURL.rawValue, userInfo: nil))
116 | }
117 |
118 | private func queryContainsKey(_ items: [URLQueryItem]?, key: String) -> Bool {
119 | guard let queryItems = items else { return false }
120 | return queryItems.filter({ $0.name.caseInsensitiveCompare(key) == .orderedSame }).count > 0
121 | }
122 |
123 | private func queryValue(_ items: [URLQueryItem]?, key: String) -> String? {
124 | guard let queryItems = items else { return nil }
125 | return queryItems.filter({ $0.name.caseInsensitiveCompare(key) == .orderedSame }).first?.value
126 | }
127 | }
128 |
--------------------------------------------------------------------------------
/NavigationEngineDemoTests/Parsing/DeepLinkFactoryTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DeepLinkFactoryTests.swift
3 | // NavigationEngineTests
4 | //
5 | // Created by Alberto De Bortoli on 07/02/2019.
6 | //
7 |
8 | import XCTest
9 | import CoreLocation
10 | @testable import NavigationEngineDemo
11 |
12 | class DeepLinkFactoryTests: XCTestCase {
13 |
14 | private let scheme = "justeat"
15 | private let host = "je.com"
16 |
17 | private var deepLinkFactory: DeepLinkFactory!
18 |
19 | override func setUp() {
20 | super.setUp()
21 | deepLinkFactory = DeepLinkFactory(scheme: scheme, host: host)
22 | }
23 |
24 | func test_homeUrl() {
25 | let target = DeepLink(string: "\(scheme)://\(host)/home")!
26 | let test = deepLinkFactory.homeURL()
27 | XCTAssertEqual(target, test)
28 | }
29 |
30 | func test_loginUrl() {
31 | let target = DeepLink(string: "\(scheme)://\(host)/login")!
32 | let test = deepLinkFactory.loginURL()
33 | XCTAssertEqual(target, test)
34 | }
35 |
36 | func test_updatePasswordUrl() {
37 | let target = DeepLink(string: "\(scheme)://\(host)/resetPassword?resetToken=qwerty")!
38 | let test = deepLinkFactory.updatePasswordURL(token: "qwerty")
39 | XCTAssertEqual(target, test)
40 | }
41 |
42 | func test_serpSearchUrl() {
43 | let target = DeepLink(string: "\(scheme)://\(host)/search")!
44 | let test = deepLinkFactory.searchURL(postcode: nil, cuisine: nil, location: nil)
45 | XCTAssertEqual(target, test)
46 | }
47 |
48 | func test_serpSearchUrlWithPostcode() {
49 | let target = DeepLink(string: "\(scheme)://\(host)/search?postcode=EC4M7RF")!
50 | let test = deepLinkFactory.searchURL(postcode: "EC4M7RF", cuisine: nil, location: nil)
51 | XCTAssertEqual(target, test)
52 | }
53 |
54 | func test_serpSearchUrlWithPostcodeAndLocation() {
55 | let target = DeepLink(string: "\(scheme)://\(host)/search?postcode=EC4M7RF&lat=45.951342&long=12.497958")!
56 | let test = deepLinkFactory.searchURL(postcode: "EC4M7RF", cuisine: nil, location: CLLocationCoordinate2D(latitude: 45.951342, longitude: 12.497958))
57 | XCTAssertEqual(target, test)
58 | }
59 |
60 | func test_serpSearchUrlWithPostcodeAndCuisine() {
61 | let target = DeepLink(string: "\(scheme)://\(host)/search?postcode=EC4M7RF&cuisine=italian")!
62 | let test = deepLinkFactory.searchURL(postcode: "EC4M7RF", cuisine: "italian", location: nil)
63 | XCTAssertEqual(target, test)
64 | }
65 |
66 | func test_serpSearchUrlWithPostcodeAndCuisineAndLocation() {
67 | let target = DeepLink(string: "\(scheme)://\(host)/search?postcode=EC4M7RF&cuisine=italian&lat=45.951342&long=12.497958")!
68 | let test = deepLinkFactory.searchURL(postcode: "EC4M7RF", cuisine: "italian", location: CLLocationCoordinate2D(latitude: 45.951342, longitude: 12.497958))
69 | XCTAssertEqual(target, test)
70 | }
71 |
72 | func test_restaurantUrl() {
73 | let target = DeepLink(string: "\(scheme)://\(host)/restaurant?restaurantId=123")!
74 | let test = deepLinkFactory.restaurantURL(restaurantId: "123", postcode: nil, serviceType: nil, section: nil)
75 | XCTAssertEqual(target, test)
76 | }
77 |
78 | func test_restaurantUrlWithPostcode() {
79 | let target = DeepLink(string: "\(scheme)://\(host)/restaurant?restaurantId=123&postcode=EC4M7RF")!
80 | let test = deepLinkFactory.restaurantURL(restaurantId: "123", postcode: "EC4M7RF", serviceType: nil, section: nil)
81 | XCTAssertEqual(target, test)
82 | }
83 |
84 | func test_restaurantUrlWithPostcodeAndServiceType() {
85 | let target = DeepLink(string: "\(scheme)://\(host)/restaurant?restaurantId=123&postcode=EC4M7RF&serviceType=delivery")!
86 | let test = deepLinkFactory.restaurantURL(restaurantId: "123", postcode: "EC4M7RF", serviceType: .delivery, section: nil)
87 | XCTAssertEqual(target, test)
88 | }
89 |
90 | func test_restaurantUrlWithPostcodeAndServiceTypeAndMenuSection() {
91 | let target = DeepLink(string: "\(scheme)://\(host)/restaurant?restaurantId=123&postcode=EC4M7RF&serviceType=delivery§ion=menu")!
92 | let test = deepLinkFactory.restaurantURL(restaurantId: "123", postcode: "EC4M7RF", serviceType: .delivery, section: .menu)
93 | XCTAssertEqual(target, test)
94 | }
95 |
96 | func test_restaurantUrlWithPostcodeAndServiceTypeAndReviewsSection() {
97 | let target = DeepLink(string: "\(scheme)://\(host)/restaurant?restaurantId=123&postcode=EC4M7RF&serviceType=delivery§ion=reviews")!
98 | let test = deepLinkFactory.restaurantURL(restaurantId: "123", postcode: "EC4M7RF", serviceType: .delivery, section: .reviews)
99 | XCTAssertEqual(target, test)
100 | }
101 |
102 | func test_restaurantUrlWithPostcodeAndServiceTypeAndInfoSection() {
103 | let target = DeepLink(string: "\(scheme)://\(host)/restaurant?restaurantId=123&postcode=EC4M7RF&serviceType=delivery§ion=info")!
104 | let test = deepLinkFactory.restaurantURL(restaurantId: "123", postcode: "EC4M7RF", serviceType: .delivery, section: .info)
105 | XCTAssertEqual(target, test)
106 | }
107 |
108 | func test_orderHistoryUrl() {
109 | let target = DeepLink(string: "\(scheme)://\(host)/orders")!
110 | let test = deepLinkFactory.orderHistoryURL()
111 | XCTAssertEqual(target, test)
112 | }
113 |
114 | func test_orderDetailsUrl() {
115 | let target = DeepLink(string: "\(scheme)://\(host)/order?orderId=123")!
116 | let test = deepLinkFactory.orderDetailsURL(orderId: "123")
117 | XCTAssertEqual(target, test)
118 | }
119 |
120 | func test_reorderUrl() {
121 | let target = DeepLink(string: "\(scheme)://\(host)/reorder?orderId=123")!
122 | let test = deepLinkFactory.reorderURL(orderId: "123")
123 | XCTAssertEqual(target, test)
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/NavigationEngineDemoTests/DeepLinkingFacadeTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // DeepLinkingFacadeTests.swift
3 | // NavigationEngineTests
4 | //
5 | // Created by Alberto De Bortoli on 07/02/2019.
6 | //
7 |
8 | import XCTest
9 | import CoreSpotlight
10 | @testable import NavigationEngineDemo
11 |
12 | class DeepLinkingFacadeTests: XCTestCase {
13 |
14 | private let timeout = 3.0
15 | private var deepLinkingFacade: DeepLinkingFacade!
16 |
17 | override func setUp() {
18 | let rootFlowController = MockRootFlowController()
19 | rootFlowController.setup()
20 | let flowControllerProvider = FlowControllerProvider(rootFlowController: rootFlowController)
21 | let navigationTransitionerDataSource = MockNavigationTransitionerDataSource(shouldSucceed: true)
22 | let settings = MockDeepLinkingSettings()
23 | let userStatusProvider = MockUserStatusProvider(userStatus: .loggedOut)
24 | deepLinkingFacade = DeepLinkingFacade(flowControllerProvider: flowControllerProvider,
25 | navigationTransitionerDataSource: navigationTransitionerDataSource,
26 | settings: settings,
27 | userStatusProvider: userStatusProvider)
28 | }
29 |
30 | func test_handleUniversalLink_success() {
31 | let expectation = self.expectation(description: #function)
32 | let url = URL(string: "http://just-eat.co.uk/home")!
33 | deepLinkingFacade.handleURL(url).finally { future in
34 | XCTAssertTrue(future.hasResult())
35 | XCTAssertEqual(future.result!, true)
36 | expectation.fulfill()
37 | }
38 | wait(for: [expectation], timeout: timeout)
39 | }
40 |
41 | func test_handleUniversalLink_failure() {
42 | let expectation = self.expectation(description: #function)
43 | let url = URL(string: "http://just-eat.com/invalid")!
44 | deepLinkingFacade.handleURL(url).finally { future in
45 | XCTAssertTrue(future.hasError())
46 | let error = future.error! as NSError
47 | XCTAssertEqual(error.domain, DeepLinkingFacade.domain)
48 | XCTAssertEqual(error.code, DeepLinkingFacade.ErrorCode.couldNotHandleURL.rawValue)
49 | expectation.fulfill()
50 | }
51 | wait(for: [expectation], timeout: timeout)
52 | }
53 |
54 | func test_openDeepLink_success() {
55 | let expectation = self.expectation(description: #function)
56 | let deepLink = DeepLink(string: "je-internal://just-eat.com/home")!
57 | deepLinkingFacade.openDeepLink(deepLink).finally { future in
58 | XCTAssertTrue(future.hasResult())
59 | XCTAssertEqual(future.result!, true)
60 | expectation.fulfill()
61 | }
62 | wait(for: [expectation], timeout: timeout)
63 | }
64 |
65 | func test_openDeepLink_failure() {
66 | let expectation = self.expectation(description: #function)
67 | let deepLink = DeepLink(string: "je-internal://just-eat.com/invalid")!
68 | deepLinkingFacade.openDeepLink(deepLink).finally { future in
69 | XCTAssertTrue(future.hasError())
70 | let error = future.error! as NSError
71 | XCTAssertEqual(error.domain, DeepLinkingFacade.domain)
72 | XCTAssertEqual(error.code, DeepLinkingFacade.ErrorCode.couldNotHandleDeepLink.rawValue)
73 | expectation.fulfill()
74 | }
75 | wait(for: [expectation], timeout: timeout)
76 | }
77 |
78 | func test_openSpotlightItem_success() {
79 | let expectation = self.expectation(description: #function)
80 | let spotlightItem = MockNSUserActivity(activityType: CSSearchableItemActionType, userInfo: [CSSearchableItemActivityIdentifier: "/restaurant/456"])
81 | deepLinkingFacade.openSpotlightItem(spotlightItem).finally { future in
82 | XCTAssertTrue(future.hasResult())
83 | XCTAssertEqual(future.result!, true)
84 | expectation.fulfill()
85 | }
86 | wait(for: [expectation], timeout: timeout)
87 | }
88 |
89 | func test_openSpotlightItem_failure() {
90 | let expectation = self.expectation(description: #function)
91 | let spotlightItem = MockNSUserActivity(activityType: CSSearchableItemActionType, userInfo: [CSSearchableItemActivityIdentifier: "/invalid"])
92 | deepLinkingFacade.openSpotlightItem(spotlightItem).finally { future in
93 | XCTAssertTrue(future.hasError())
94 | let error = future.error! as NSError
95 | XCTAssertEqual(error.domain, DeepLinkingFacade.domain)
96 | XCTAssertEqual(error.code, DeepLinkingFacade.ErrorCode.couldNotDeepLinkFromSpotlightItem.rawValue)
97 | expectation.fulfill()
98 | }
99 | wait(for: [expectation], timeout: timeout)
100 | }
101 |
102 | func test_openShortcutItem_success() {
103 | let expectation = self.expectation(description: #function)
104 | let shortcutItem = UIApplicationShortcutItem(type: "/search", localizedTitle: "Search")
105 | deepLinkingFacade.openShortcutItem(shortcutItem).finally { future in
106 | XCTAssertTrue(future.hasResult())
107 | XCTAssertEqual(future.result!, true)
108 | expectation.fulfill()
109 | }
110 | wait(for: [expectation], timeout: timeout)
111 | }
112 |
113 | func test_openShortcutItem_failure() {
114 | let expectation = self.expectation(description: #function)
115 | let shortcutItem = UIApplicationShortcutItem(type: "/invalid", localizedTitle: "Invalid")
116 | deepLinkingFacade.openShortcutItem(shortcutItem).finally { future in
117 | XCTAssertTrue(future.hasError())
118 | let error = future.error! as NSError
119 | XCTAssertEqual(error.domain, DeepLinkingFacade.domain)
120 | XCTAssertEqual(error.code, DeepLinkingFacade.ErrorCode.couldNotDeepLinkFromShortcutItem.rawValue)
121 | expectation.fulfill()
122 | }
123 | wait(for: [expectation], timeout: timeout)
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Navigation/StateMachine.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StateMachine.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 25/12/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import Stateful
11 |
12 | struct TransitionOptions: OptionSet {
13 | let rawValue: Int
14 |
15 | static let basicTransitions = TransitionOptions(rawValue: 1 << 0)
16 | static let userLoggedIn = TransitionOptions(rawValue: 1 << 1)
17 | static let userLoggedOut = TransitionOptions(rawValue: 1 << 2)
18 | }
19 |
20 | enum EventType: Int, CaseIterable {
21 | case popEverything
22 | case goToHome
23 | case goToLogin
24 | case goToResetPassword
25 | case searchRestaurants
26 | case loadRestaurant
27 | case fillBasket
28 | case loadOrderHistory
29 | case goToOrderDetails
30 | case goToSettings
31 | }
32 |
33 | enum StateType: Int, CaseIterable {
34 | case allPoppedToRoot
35 | case home
36 | case login
37 | case resetPassword
38 | case search
39 | case basket
40 | case restaurant
41 | case orderHistory
42 | case orderDetails
43 | case settings
44 | }
45 |
46 | class StateMachine: Stateful.StateMachine {
47 |
48 | init(initialState: StateType, allowedTransitions: TransitionOptions) {
49 | super.init(initialState: initialState)
50 |
51 | if allowedTransitions.contains(.basicTransitions) {
52 | addBasicTransitions()
53 | }
54 |
55 | if allowedTransitions.contains(.userLoggedIn) {
56 | addUserLoggedInTransitions()
57 | }
58 |
59 | if allowedTransitions.contains(.userLoggedOut) {
60 | addUserLoggedOutTransitions()
61 | }
62 | }
63 |
64 | func addBasicTransitions() {
65 | add(transition: Transition(with: .goToHome,
66 | from: .allPoppedToRoot,
67 | to: .home))
68 | add(transition: Transition(with: .searchRestaurants,
69 | from: .home,
70 | to: .search))
71 | add(transition: Transition(with: .loadRestaurant,
72 | from: .home,
73 | to: .restaurant))
74 | add(transition: Transition(with: .loadRestaurant,
75 | from: .search,
76 | to: .restaurant))
77 | add(transition: Transition(with: .goToSettings,
78 | from: .allPoppedToRoot,
79 | to: .settings))
80 |
81 | // make sure we can always go back to root
82 | add(transition: Transition(with: .popEverything,
83 | from: .allPoppedToRoot,
84 | to: .allPoppedToRoot))
85 | add(transition: Transition(with: .popEverything,
86 | from: .home,
87 | to: .allPoppedToRoot))
88 | add(transition: Transition(with: .popEverything,
89 | from: .resetPassword,
90 | to: .allPoppedToRoot))
91 | add(transition: Transition(with: .popEverything,
92 | from: .search,
93 | to: .allPoppedToRoot))
94 | add(transition: Transition(with: .popEverything,
95 | from: .restaurant,
96 | to: .allPoppedToRoot))
97 | add(transition: Transition(with: .popEverything,
98 | from: .orderHistory,
99 | to: .allPoppedToRoot))
100 | add(transition: Transition(with: .popEverything,
101 | from: .orderDetails,
102 | to: .allPoppedToRoot))
103 | }
104 |
105 | func addUserLoggedInTransitions() {
106 | add(transition: Transition(with: .loadOrderHistory,
107 | from: .allPoppedToRoot,
108 | to: .orderHistory))
109 | add(transition: Transition(with: .goToOrderDetails,
110 | from: .orderHistory,
111 | to: .orderDetails))
112 | add(transition: Transition(with: .fillBasket,
113 | from: .restaurant,
114 | to: .basket))
115 | }
116 |
117 | func addUserLoggedOutTransitions() {
118 | add(transition: Transition(with: .goToResetPassword,
119 | from: .allPoppedToRoot,
120 | to: .resetPassword))
121 | add(transition: Transition(with: .goToLogin,
122 | from: .allPoppedToRoot,
123 | to: .login))
124 | }
125 | }
126 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Navigation/NavigationTransitioner.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NavigationTransitioner.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 26/12/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import Stateful
11 | import Promis
12 |
13 | public protocol NavigationTransitionerDataSource: class {
14 |
15 | func navigationTransitionerDidRequestInputForReorder(orderId: OrderId) -> Future
16 | func navigationTransitionerDidRequestUserToLogin() -> Future
17 | }
18 |
19 | class NavigationTransitioner {
20 |
21 | weak var dataSource: NavigationTransitionerDataSource!
22 |
23 | let flowControllerProvider: FlowControllerProvider
24 | let stateMachine: StateMachine
25 |
26 | static let domain = "com.justeat.navigationTransitioner"
27 |
28 | enum ErrorCode: Int {
29 | case failedPerformingTransition
30 | }
31 |
32 | init(flowControllerProvider: FlowControllerProvider, stateMachine: StateMachine) {
33 | self.flowControllerProvider = flowControllerProvider
34 | self.stateMachine = stateMachine
35 | }
36 |
37 | func goToRoot(animated: Bool) -> Future {
38 | return performTransition(forEvent: .popEverything, autoclosure:
39 | self.flowControllerProvider.rootFlowController.dismissAndPopToRootAll(animated: animated)
40 | )
41 | }
42 |
43 | func goToHome(animated: Bool) -> Future {
44 | return performTransition(forEvent: .goToHome, autoclosure:
45 | self.flowControllerProvider.rootFlowController.goToRestaurantsSection()
46 | )
47 | }
48 |
49 | func goToLogin(animated: Bool) -> Future {
50 | return performTransition(forEvent: .goToLogin, autoclosure:
51 | self.flowControllerProvider.rootFlowController.goToLogin(animated: animated)
52 | )
53 | }
54 |
55 | func goToResetPassword(token: ResetPasswordToken, animated: Bool) -> Future {
56 | return performTransition(forEvent: .goToResetPassword, autoclosure:
57 | self.flowControllerProvider.rootFlowController.goToResetPassword(token: token, animated: animated)
58 | )
59 | }
60 |
61 | func goFromHomeToSearch(postcode: Postcode?, cuisine: Cuisine?, animated: Bool) -> Future {
62 | return performTransition(forEvent: .searchRestaurants, autoclosure:
63 | self.flowControllerProvider.restaurantsFlowController.goToSearchAnimated(postcode: postcode, cuisine: cuisine, animated: animated)
64 | )
65 | }
66 |
67 | func goFromHomeToRestaurant(restaurantId: RestaurantId, postcode: Postcode?, serviceType: ServiceType?, section: RestaurantSection?, animated: Bool) -> Future {
68 | return performTransition(forEvent: .loadRestaurant, autoclosure:
69 | self.flowControllerProvider.restaurantsFlowController.goToRestaurant(restaurantId: restaurantId, postcode: postcode, serviceType: serviceType, section: section, animated: animated)
70 | )
71 | }
72 |
73 | func goFromSearchToRestaurant(restaurantId: RestaurantId, postcode: Postcode?, serviceType: ServiceType?, section: RestaurantSection?, animated: Bool) -> Future {
74 | return performTransition(forEvent: .loadRestaurant, autoclosure:
75 | self.flowControllerProvider.restaurantsFlowController.goToRestaurant(restaurantId: restaurantId, postcode: postcode, serviceType: serviceType, section: section, animated: animated)
76 | )
77 | }
78 |
79 | func goFromRestaurantToBasket(reorderId: ReorderId, animated: Bool) -> Future {
80 | return performTransition(forEvent: .fillBasket, autoclosure:
81 | self.flowControllerProvider.restaurantsFlowController.goToBasket(reorderId: reorderId, animated: animated)
82 | )
83 | }
84 |
85 | func goToOrderHistory(animated: Bool) -> Future {
86 | return performTransition(forEvent: .loadOrderHistory, autoclosure:
87 | self.flowControllerProvider.rootFlowController.goToOrdersSection()
88 | )
89 | }
90 |
91 | func goFromOrderHistoryToOrderDetails(orderId: OrderId, animated: Bool) -> Future {
92 | return performTransition(forEvent: .goToOrderDetails, autoclosure:
93 | self.flowControllerProvider.ordersFlowController.goToOrder(orderId: orderId, animated: animated)
94 | )
95 | }
96 |
97 | func goToSettings(animated: Bool) -> Future {
98 | return performTransition(forEvent: .goToSettings, autoclosure:
99 | self.flowControllerProvider.rootFlowController.goToSettingsSection()
100 | )
101 | }
102 |
103 | func requestInputForReorder(orderId: OrderId) -> Future {
104 | return dataSource.navigationTransitionerDidRequestInputForReorder(orderId: orderId)
105 | }
106 |
107 | func requestUserToLogin() -> Future {
108 | return dataSource.navigationTransitionerDidRequestUserToLogin()
109 | }
110 | }
111 |
112 | extension NavigationTransitioner {
113 |
114 | fileprivate func performTransition(forEvent eventType: EventType, autoclosure: @autoclosure @escaping () -> Future) -> Future {
115 | let promise = Promise()
116 | stateMachine.process(event: eventType, execution: {
117 | autoclosure().finally { future in
118 | promise.setResolution(of: future)
119 | }
120 | }, callback: { result in
121 | /**
122 | * If result == .success (i.e. the transition was performed):
123 | - the callback block is called twice
124 | - the `execution` block is called
125 | * If result == .failure (i.e. the transition cannot be performed):
126 | - the callback block is called once
127 | - the `execution` block is not called
128 | * We cannot therefore resolve the promise in case of success otherwise we would do it twice causing a crash.
129 | */
130 | if result == .failure {
131 | let error = NSError(domain: NavigationTransitioner.domain,
132 | code: ErrorCode.failedPerformingTransition.rawValue,
133 | userInfo: [NSLocalizedFailureReasonErrorKey: "Could not perform transition"])
134 | promise.setError(error)
135 | }
136 | })
137 | return promise.future
138 | }
139 | }
140 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/DemoApp/NavigationEngineIntegration/FlowControllers/RootFlowController.swift:
--------------------------------------------------------------------------------
1 | //
2 | // RootFlowController.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 23/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import Promis
11 |
12 | class RootFlowController: RootFlowControllerProtocol {
13 |
14 | private let tabBarController: TabBarController
15 |
16 | var restaurantsFlowController: RestaurantsFlowControllerProtocol!
17 | var ordersFlowController: OrdersFlowControllerProtocol!
18 | var settingsFlowController: SettingsFlowControllerProtocol!
19 |
20 | var accountFlowController: AccountFlowControllerProtocol!
21 |
22 | init(with tabBarController: TabBarController) {
23 | self.tabBarController = tabBarController
24 | }
25 |
26 | func setup() {
27 | let takeawayNavigationController = tabBarController.viewControllers![0] as! UINavigationController
28 | let restaurantsFlowController = RestaurantsFlowController(with: self, navigationController: takeawayNavigationController)
29 | restaurantsFlowController.delegate = self
30 | self.restaurantsFlowController = restaurantsFlowController
31 | let homeViewController = takeawayNavigationController.topViewController as! HomeViewController
32 | homeViewController.flowController = restaurantsFlowController
33 |
34 | let ordersNavigationController = tabBarController.viewControllers![1] as! UINavigationController
35 | let ordersFlowController = OrdersFlowController(with: self, navigationController: ordersNavigationController)
36 | ordersFlowController.delegate = self
37 | self.ordersFlowController = ordersFlowController
38 | let orderHistoryViewController = ordersNavigationController.topViewController as! OrderHistoryViewController
39 | orderHistoryViewController.flowController = ordersFlowController
40 |
41 | let settingsNavigationController = tabBarController.viewControllers![2] as! UINavigationController
42 | let settingsFlowController = SettingsFlowController(with: self, navigationController: settingsNavigationController)
43 | let settingsViewController = settingsNavigationController.topViewController as! SettingsViewController
44 | self.settingsFlowController = settingsFlowController
45 | settingsViewController.flowController = settingsFlowController
46 | }
47 |
48 | @discardableResult
49 | func dismissAll(animated: Bool) -> Future {
50 | // cannot use the completion to fulfill any future since it might never get called
51 | tabBarController.dismiss(animated: animated)
52 | let fut1 = restaurantsFlowController.dismiss(animated: animated)
53 | let fut2 = ordersFlowController.dismiss(animated: animated)
54 | let fut3 = settingsFlowController.dismiss(animated: animated)
55 | return Future.whenAll([fut1, fut2, fut3]).then { future in
56 | let erroredFuture = [fut1, fut2, fut3].filter { $0.hasError() }
57 | if let firstErroredFuture = erroredFuture.first {
58 | return Future.future(withError: firstErroredFuture.error!)
59 | }
60 | return Future.future(withResult: fut1.result! && fut2.result! && fut3.result!)
61 | }
62 | }
63 |
64 | @discardableResult
65 | func dismissAndPopToRootAll(animated: Bool) -> Future {
66 | let fut0 = dismissAll(animated: animated)
67 | let fut1 = restaurantsFlowController.goBackToRoot(animated: animated)
68 | let fut2 = ordersFlowController.goBackToRoot(animated: animated)
69 | let fut3 = settingsFlowController.goBackToRoot(animated: animated)
70 | return Future.whenAll([fut0, fut1, fut2, fut3]).then { future in
71 | let erroredFuture = [fut0, fut1, fut2, fut3].filter { $0.hasError() }
72 | if let firstErroredFuture = erroredFuture.first {
73 | return Future.future(withError: firstErroredFuture.error!)
74 | }
75 | return Future.future(withResult: fut0.result! && fut1.result! && fut2.result! && fut3.result!)
76 | }
77 | }
78 |
79 | @discardableResult
80 | func goToLogin(animated: Bool) -> Future {
81 | let accountNC = UINavigationController()
82 | accountFlowController = AccountFlowController(with: self, navigationController: accountNC)
83 | return accountFlowController.beginLoginFlow(from: tabBarController, animated: animated)
84 | }
85 |
86 | @discardableResult
87 | func goToResetPassword(token: ResetPasswordToken, animated: Bool) -> Future {
88 | let accountNC = UINavigationController()
89 | accountFlowController = AccountFlowController(with: self, navigationController: accountNC)
90 | return accountFlowController.beginResetPasswordFlow(from: tabBarController, token: token, animated: animated)
91 | }
92 |
93 | @discardableResult
94 | func goToRestaurantsSection() -> Future {
95 | tabBarController.selectedIndex = 0
96 | return Future.future(withResult: true)
97 | }
98 |
99 | @discardableResult
100 | func goToOrdersSection() -> Future {
101 | tabBarController.selectedIndex = 2
102 | return Future.future(withResult: true)
103 | }
104 |
105 | @discardableResult
106 | func goToSettingsSection() -> Future {
107 | tabBarController.selectedIndex = 3
108 | return Future.future(withResult: true)
109 | }
110 | }
111 |
112 | extension RootFlowController: OrdersFlowControllerDelegate {
113 |
114 | func ordersFlowController(_ flowController: OrdersFlowController, didRequestGoingToRestaurant restaurantId: String) -> Future {
115 | return goToRestaurantsSection().then { future in
116 | self.restaurantsFlowController.goBackToRoot(animated: false)
117 | }.then { future in
118 | self.restaurantsFlowController.goToRestaurant(restaurantId: restaurantId, postcode: nil, serviceType: nil, section: nil, animated: false)
119 | }
120 | }
121 | }
122 |
123 | extension RootFlowController: RestaurantsFlowControllerDelegate {
124 |
125 | func restaurantsFlowController(_ flowController: RestaurantsFlowController, didRequestGoingToOrder orderId: OrderId) -> Future {
126 | return dismissAndPopToRootAll(animated: false).then { future in
127 | return self.goToOrdersSection()
128 | }.then { future in
129 | self.ordersFlowController.goToOrder(orderId: orderId, animated: false)
130 | }
131 | }
132 | }
133 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Navigation/NavigationIntentHandler.swift:
--------------------------------------------------------------------------------
1 | //
2 | // IntentHandler.swift
3 | // NavigationEngineDemo
4 | //
5 | // Created by Alberto De Bortoli on 24/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import Promis
11 |
12 | public class NavigationIntentHandler: NavigationIntentHandling {
13 |
14 | let flowControllerProvider: FlowControllerProvider
15 | let userStatusProvider: UserStatusProviding!
16 | private let navigationTransitionerDataSource: NavigationTransitionerDataSource
17 | var navigationTransitioner: NavigationTransitioner!
18 |
19 | init(flowControllerProvider: FlowControllerProvider, userStatusProvider: UserStatusProviding, navigationTransitionerDataSource: NavigationTransitionerDataSource) {
20 | self.flowControllerProvider = flowControllerProvider
21 | self.userStatusProvider = userStatusProvider
22 | self.navigationTransitionerDataSource = navigationTransitionerDataSource
23 | }
24 |
25 | func handleIntent(_ intent: NavigationIntent) -> Future {
26 | let allowedTransitions: TransitionOptions = {
27 | switch userStatusProvider.userStatus {
28 | case .loggedIn:
29 | return [.basicTransitions, .userLoggedIn]
30 | case .loggedOut:
31 | return [.basicTransitions, .userLoggedOut]
32 | }
33 | }()
34 |
35 | let stateMachine = StateMachine(initialState: StateType.allPoppedToRoot, allowedTransitions: allowedTransitions)
36 | navigationTransitioner = NavigationTransitioner(flowControllerProvider: flowControllerProvider, stateMachine: stateMachine)
37 | navigationTransitioner.dataSource = navigationTransitionerDataSource
38 |
39 | switch intent {
40 | case .goToHome:
41 | return navigationTransitioner.goToRoot(animated: false).thenWithResult { _ -> Future in
42 | self.navigationTransitioner.goToHome(animated: false)
43 | }
44 | case .goToLogin:
45 | return navigationTransitioner.goToRoot(animated: false).thenWithResult { _ -> Future in
46 | self.navigationTransitioner.goToLogin(animated: true)
47 | }
48 | case .goToResetPassword(let token):
49 | return navigationTransitioner.goToRoot(animated: false).thenWithResult { _ -> Future in
50 | self.navigationTransitioner.goToResetPassword(token: token, animated: true)
51 | }
52 | case .goToSearch(let postcode, let cuisine):
53 | return navigationTransitioner.goToRoot(animated: false).thenWithResult { _ -> Future in
54 | self.navigationTransitioner.goToHome(animated: false)
55 | }.thenWithResult { _ -> Future in
56 | self.navigationTransitioner.goFromHomeToSearch(postcode: postcode, cuisine: cuisine, animated: true)
57 | }
58 | case .goToRestaurant(let restaurantId, let postcode, let serviceType, let section):
59 | return navigationTransitioner.goToRoot(animated: false).thenWithResult { _ -> Future in
60 | self.navigationTransitioner.goToHome(animated: false)
61 | }.thenWithResult { _ -> Future in
62 | self.navigationTransitioner.goFromHomeToRestaurant(restaurantId: restaurantId, postcode: postcode, serviceType: serviceType, section: section, animated: true)
63 | }
64 | case .goToReorder(let reorderId):
65 | switch userStatusProvider.userStatus {
66 | case .loggedIn:
67 | return navigationTransitioner.goToRoot(animated: false).thenWithResult { _ -> Future in
68 | self.navigationTransitioner.goToHome(animated: false)
69 | }.thenWithResult { _ -> Future in
70 | self.navigationTransitioner.requestInputForReorder(orderId: reorderId)
71 | }.thenWithResult { reorderInfo -> Future in
72 | self.navigationTransitioner.goFromHomeToRestaurant(restaurantId: reorderInfo.restaurantId,
73 | postcode: reorderInfo.postcode,
74 | serviceType: reorderInfo.serviceType,
75 | section: .menu,
76 | animated: false)
77 | }.thenWithResult { _ -> Future in
78 | self.navigationTransitioner.goFromRestaurantToBasket(reorderId: reorderId, animated: true)
79 | }
80 | case .loggedOut:
81 | return navigationTransitioner.requestUserToLogin().then { future in
82 | switch future.state {
83 | case .result:
84 | return self.handleIntent(intent) // go recursive
85 | default:
86 | return Future.futureWithResolution(of: future)
87 | }
88 | }
89 | }
90 | case .goToOrderHistory:
91 | switch userStatusProvider.userStatus {
92 | case .loggedIn:
93 | return navigationTransitioner.goToRoot(animated: false).thenWithResult { _ -> Future in
94 | self.navigationTransitioner.goToOrderHistory(animated: true)
95 | }
96 | case .loggedOut:
97 | return navigationTransitioner.requestUserToLogin().then { future in
98 | switch future.state {
99 | case .result:
100 | return self.handleIntent(intent) // go recursive
101 | default:
102 | return Future.futureWithResolution(of: future)
103 | }
104 | }
105 | }
106 | case .goToOrderDetails(let orderId):
107 | switch userStatusProvider.userStatus {
108 | case .loggedIn:
109 | return navigationTransitioner.goToRoot(animated: false).thenWithResult { _ -> Future in
110 | self.navigationTransitioner.goToOrderHistory(animated: false)
111 | }.thenWithResult { _ -> Future in
112 | self.navigationTransitioner.goFromOrderHistoryToOrderDetails(orderId: orderId, animated: true)
113 | }
114 | case .loggedOut:
115 | return navigationTransitioner.requestUserToLogin().then { future in
116 | switch future.state {
117 | case .result:
118 | return self.handleIntent(intent) // go recursive
119 | default:
120 | return Future.futureWithResolution(of: future)
121 | }
122 | }
123 | }
124 | case .goToSettings:
125 | return navigationTransitioner.goToRoot(animated: false).thenWithResult { _ -> Future in
126 | self.navigationTransitioner.goToSettings(animated: false)
127 | }
128 | }
129 | }
130 | }
131 |
--------------------------------------------------------------------------------
/NavigationEngineDemoTests/Navigation/StateMachineTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // StateMachineTests.swift
3 | // NavigationEngineTests
4 | //
5 | // Created by Alberto De Bortoli on 07/02/2019.
6 | //
7 |
8 | import XCTest
9 | import Stateful
10 | @testable import NavigationEngineDemo
11 |
12 | extension Transition: Equatable {
13 |
14 | public static func == (lhs: Transition, rhs: Transition) -> Bool {
15 | return lhs.source as! StateType == rhs.source as! StateType &&
16 | lhs.event as! EventType == rhs.event as! EventType
17 | // don't do the equality on the destination since the ST only allows adding one event given a source state
18 | }
19 | }
20 |
21 | class StateMachineTests: XCTestCase {
22 |
23 | func test_onlyBasicTransitionsAreAllowed() {
24 | let options: TransitionOptions = [.basicTransitions]
25 | allowedTransitions(basicTransitions, options: options)
26 | }
27 |
28 | func test_onlyBasicAndUserLoggedInTransitionsAreAllowed() {
29 | let options: TransitionOptions = [.basicTransitions, .userLoggedIn]
30 | allowedTransitions(basicTransitions + userLoggedInTransitions, options: options)
31 | }
32 |
33 | func test_onlyBasicAndUserLoggedOutTransitionsAreAllowed() {
34 | let options: TransitionOptions = [.basicTransitions, .userLoggedOut]
35 | allowedTransitions(basicTransitions + userLoggedOutTransitions, options: options)
36 | }
37 |
38 | private func allowedTransitions(_ transitions: [Transition], options: TransitionOptions) {
39 | for s1 in 0..(with: e, from: state1, to: state2)
46 | let sm = StateMachine(initialState: state1, allowedTransitions: options)
47 | sm.process(event: e, execution: nil) { result in
48 | if transitions.contains(t) {
49 | XCTAssert(result == .success)
50 | }
51 | else {
52 | XCTAssert(result == .failure)
53 | }
54 | }
55 | }
56 | }
57 | }
58 | }
59 |
60 | lazy var basicTransitions: [Transition] = {
61 | var transitions = [Transition]()
62 | transitions.append(Transition(with: .goToHome,
63 | from: .allPoppedToRoot,
64 | to: .home))
65 | transitions.append(Transition(with: .searchRestaurants,
66 | from: .home,
67 | to: .search))
68 | transitions.append(Transition(with: .loadRestaurant,
69 | from: .home,
70 | to: .restaurant))
71 | transitions.append(Transition(with: .loadRestaurant,
72 | from: .search,
73 | to: .restaurant))
74 | transitions.append(Transition(with: .goToSettings,
75 | from: .allPoppedToRoot,
76 | to: .settings))
77 |
78 | // make sure we can always go back to root
79 | transitions.append(Transition(with: .popEverything,
80 | from: .allPoppedToRoot,
81 | to: .allPoppedToRoot))
82 | transitions.append(Transition(with: .popEverything,
83 | from: .home,
84 | to: .allPoppedToRoot))
85 | transitions.append(Transition(with: .popEverything,
86 | from: .resetPassword,
87 | to: .allPoppedToRoot))
88 | transitions.append(Transition(with: .popEverything,
89 | from: .search,
90 | to: .allPoppedToRoot))
91 | transitions.append(Transition(with: .popEverything,
92 | from: .restaurant,
93 | to: .allPoppedToRoot))
94 | transitions.append(Transition(with: .popEverything,
95 | from: .orderHistory,
96 | to: .allPoppedToRoot))
97 | transitions.append(Transition(with: .popEverything,
98 | from: .orderDetails,
99 | to: .allPoppedToRoot))
100 | return transitions
101 | }()
102 |
103 | lazy var userLoggedInTransitions: [Transition] = {
104 | var transitions = [Transition]()
105 | transitions.append(Transition(with: .loadOrderHistory,
106 | from: .allPoppedToRoot,
107 | to: .orderHistory))
108 | transitions.append(Transition(with: .fillBasket,
109 | from: .restaurant,
110 | to: .basket))
111 | transitions.append(Transition(with: .goToOrderDetails,
112 | from: .orderHistory,
113 | to: .orderDetails))
114 | return transitions
115 | }()
116 |
117 | lazy var userLoggedOutTransitions: [Transition] = {
118 | var transitions = [Transition]()
119 | transitions.append(Transition(with: .goToResetPassword,
120 | from: .allPoppedToRoot,
121 | to: .resetPassword))
122 | transitions.append(Transition(with: .goToLogin,
123 | from: .allPoppedToRoot,
124 | to: .login))
125 | return transitions
126 | }()
127 | }
128 |
--------------------------------------------------------------------------------
/NavigationEngineDemo/NavigationEngine/Core/Parsing/UniversalLinkConverter.swift:
--------------------------------------------------------------------------------
1 | //
2 | // GenericURIConverter.swift
3 | // JUSTEAT
4 | //
5 | // Created by Alberto De Bortoli on 13/10/2015.
6 | // Copyright © 2015 JUST EAT. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import CoreLocation
11 |
12 | class UniversalLinkConverter {
13 |
14 | private lazy var deepLinkFactory: DeepLinkFactory = {
15 | return DeepLinkFactory(scheme: settings.internalDeepLinkSchemes.first!, host: settings.internalDeepLinkHost)
16 | }()
17 |
18 | private let settings: DeepLinkingSettingsProtocol
19 |
20 | init(settings: DeepLinkingSettingsProtocol) {
21 | self.settings = settings
22 | }
23 |
24 | func deepLink(forUniversalLink url: UniversalLink) -> DeepLink? {
25 | guard
26 | let components = URLComponents(url: url, resolvingAgainstBaseURL: true),
27 | let pathComponents = components.url?.pathComponents,
28 | let scheme = components.scheme,
29 | let host = components.host,
30 | settings.universalLinkHosts.contains(host),
31 | settings.universalLinkSchemes.contains(scheme) else { return nil }
32 |
33 | let queryItems = components.queryItems
34 |
35 | switch pathComponents.count {
36 | case 4:
37 | switch (pathComponents[1], pathComponents[2], pathComponents[3]) {
38 | case ("area", let postcodeString, let cuisine):
39 | let postcode = self.postcodeFromString(postcodeString)
40 | let location: CLLocationCoordinate2D? = {
41 | if let latitude = queryValue(queryItems, key: "lat"), let longitude = queryValue(queryItems, key: "long"),
42 | let latitudeDoubleValue = Double(latitude), let longitudeDoubleValue = Double(longitude) {
43 | return CLLocationCoordinate2D(latitude: latitudeDoubleValue, longitude: longitudeDoubleValue)
44 | }
45 | return nil
46 | }()
47 | return deepLinkFactory.searchURL(postcode: postcode, cuisine: cuisine, location: location)
48 | case ("account", "order", let orderId):
49 | return deepLinkFactory.orderDetailsURL(orderId: orderId)
50 | default:
51 | return nil
52 | }
53 | case 3:
54 | switch (pathComponents[1], pathComponents[2]) {
55 | case ("pages", "orderstatus.aspx") where queryContainsKey(queryItems, key: "orderId"):
56 | let orderId = queryValue(queryItems, key: "orderId")!
57 | return deepLinkFactory.orderDetailsURL(orderId: orderId)
58 | case ("account", "update-password") where queryContainsKey(queryItems, key: "token"):
59 | let token = queryValue(queryItems, key: "token")!
60 | return deepLinkFactory.updatePasswordURL(token: token)
61 | case ("account", "login"):
62 | return deepLinkFactory.loginURL()
63 | case ("account", "order-history"):
64 | return deepLinkFactory.orderHistoryURL()
65 | case ("orders", let orderId):
66 | return deepLinkFactory.orderDetailsURL(orderId: orderId)
67 | case ("order", let orderId):
68 | return deepLinkFactory.orderDetailsURL(orderId: orderId)
69 | case ("reorder", let orderId):
70 | return deepLinkFactory.reorderURL(orderId: orderId)
71 | case ("area", let postcodeString):
72 | let postcode = self.postcodeFromString(postcodeString)
73 | let cuisine = queryValue(queryItems, key: "cuisine")
74 | let location: CLLocationCoordinate2D? = {
75 | if let latitude = queryValue(queryItems, key: "lat"), let longitude = queryValue(queryItems, key: "long"),
76 | let latitudeDoubleValue = Double(latitude), let longitudeDoubleValue = Double(longitude) {
77 | return CLLocationCoordinate2D(latitude: latitudeDoubleValue, longitude: longitudeDoubleValue)
78 | }
79 | return nil
80 | }()
81 | return deepLinkFactory.searchURL(postcode: postcode, cuisine: cuisine, location: location)
82 | default:
83 | return nil
84 | }
85 |
86 | case 2:
87 | switch pathComponents[1] {
88 | case ("home"):
89 | return deepLinkFactory.homeURL()
90 | case ("login"):
91 | return deepLinkFactory.loginURL()
92 | case ("search") where queryContainsKey(queryItems, key: "postcode"):
93 | let postcode = queryValue(queryItems, key: "postcode")!
94 | let cuisine = queryValue(queryItems, key: "cuisine")
95 | return deepLinkFactory.searchURL(postcode: postcode, cuisine: cuisine, location: nil)
96 | case ("search"):
97 | return deepLinkFactory.searchURL(postcode: nil, cuisine: nil, location: nil)
98 | case ("restaurant") where queryContainsKey(queryItems, key: "restaurantId"):
99 | let restaurantId = queryValue(queryItems, key: "restaurantId")!
100 | let postcode = queryValue(queryItems, key: "postcode")
101 | let serviceType: ServiceType? = {
102 | guard let val = queryValue(queryItems, key: "serviceType") else { return nil }
103 | return ServiceType(rawValue: val)
104 | }()
105 | let section: RestaurantSection? = {
106 | guard let val = queryValue(queryItems, key: "section") else { return nil }
107 | return RestaurantSection(rawValue: val)
108 | }()
109 | return deepLinkFactory.restaurantURL(restaurantId: restaurantId, postcode: postcode, serviceType: serviceType, section: section)
110 | case ("resetPassword") where queryContainsKey(queryItems, key: "resetToken"):
111 | let token = queryValue(queryItems, key: "resetToken")!
112 | return deepLinkFactory.updatePasswordURL(token: token)
113 | case ("orders") where queryContainsKey(queryItems, key: "orderId"):
114 | let orderId = queryValue(queryItems, key: "orderId")!
115 | return deepLinkFactory.orderDetailsURL(orderId: orderId)
116 | case ("orders"):
117 | return deepLinkFactory.orderHistoryURL()
118 | case ("order-history"):
119 | return deepLinkFactory.orderHistoryURL()
120 | case ("order") where queryContainsKey(queryItems, key: "orderId"):
121 | let orderId = queryValue(queryItems, key: "orderId")!
122 | return deepLinkFactory.orderDetailsURL(orderId: orderId)
123 | case ("reorder") where queryContainsKey(queryItems, key: "orderId"):
124 | let orderId = queryValue(queryItems, key: "orderId")!
125 | return deepLinkFactory.reorderURL(orderId: orderId)
126 | default:
127 | return nil
128 | }
129 |
130 | case 1:
131 | return deepLinkFactory.homeURL()
132 |
133 | default:
134 | return deepLinkFactory.homeURL()
135 | }
136 | }
137 |
138 | fileprivate func queryContainsKey(_ items: [URLQueryItem]?, key: String) -> Bool {
139 | guard let queryItems = items else { return false }
140 | return queryItems.filter({ $0.name.caseInsensitiveCompare(key) == .orderedSame }).count > 0
141 | }
142 |
143 | fileprivate func queryValue(_ items: [URLQueryItem]?, key: String) -> String? {
144 | guard let queryItems = items else { return nil }
145 | return queryItems.filter({ $0.name.caseInsensitiveCompare(key) == .orderedSame }).first?.value
146 | }
147 |
148 | fileprivate func postcodeFromString(_ string: String) -> String {
149 | let areaComponents = string.components(separatedBy: "-")
150 |
151 | if areaComponents.count > 0 {
152 | return areaComponents.first!
153 | }
154 |
155 | return string
156 | }
157 | }
158 |
--------------------------------------------------------------------------------
/NavigationEngineDemoTests/Navigation/NavigationIntentHandlerTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // NavigationIntentHandlerTests.swift
3 | // NavigationEngineTests
4 | //
5 | // Created by Alberto De Bortoli on 07/02/2019.
6 | //
7 |
8 | import XCTest
9 | @testable import NavigationEngineDemo
10 |
11 | class NavigationIntentHandlerTests: XCTestCase {
12 |
13 | private let timeout = 3.0
14 | private var navigationIntentHandler: NavigationIntentHandler!
15 |
16 | func setUp(userStatus: UserStatus, dataSourceShouldSucceed: Bool = true) {
17 | let rootFlowController = MockRootFlowController()
18 | rootFlowController.setup()
19 | let flowControllerProvider = FlowControllerProvider(rootFlowController: rootFlowController)
20 | let userStatusProvider = MockUserStatusProvider(userStatus: userStatus)
21 | let navigationTransitionerDataSource = MockNavigationTransitionerDataSource(shouldSucceed: dataSourceShouldSucceed, userStatusProvider: userStatusProvider)
22 | navigationIntentHandler = NavigationIntentHandler(flowControllerProvider: flowControllerProvider,
23 | userStatusProvider: userStatusProvider,
24 | navigationTransitionerDataSource: navigationTransitionerDataSource)
25 | }
26 |
27 | func test_goToHome() {
28 | wait(for: [
29 | goToHome(status: .loggedOut, shouldSucceed: true),
30 | goToHome(status: .loggedIn, shouldSucceed: true)
31 | ], timeout: timeout)
32 | }
33 |
34 | func test_goToLogin() {
35 | wait(for: [
36 | goToLogin(status: .loggedOut, shouldSucceed: true),
37 | goToLogin(status: .loggedIn, shouldSucceed: false)
38 | ], timeout: timeout)
39 | }
40 |
41 | func test_goToResetPassword() {
42 | wait(for: [
43 | goToResetPassword(status: .loggedOut, shouldSucceed: true),
44 | goToResetPassword(status: .loggedIn, shouldSucceed: false)
45 | ], timeout: timeout)
46 | }
47 |
48 | func test_goToSearch() {
49 | wait(for: [
50 | goToSearch(status: .loggedOut, shouldSucceed: true),
51 | goToSearch(status: .loggedIn, shouldSucceed: true)
52 | ], timeout: timeout)
53 | }
54 |
55 | func test_goToRestaurant() {
56 | wait(for: [
57 | goToRestaurant(status: .loggedOut, shouldSucceed: true),
58 | goToRestaurant(status: .loggedIn, shouldSucceed: true)
59 | ], timeout: timeout)
60 | }
61 |
62 | func test_goToOrderHistory() {
63 | wait(for: [
64 | goToOrderHistory(status: .loggedOut, shouldSucceed: false, dataSourceShouldSucceed: false),
65 | goToOrderHistory(status: .loggedOut, shouldSucceed: true, dataSourceShouldSucceed: true),
66 | goToOrderHistory(status: .loggedIn, shouldSucceed: true, dataSourceShouldSucceed: true)
67 | ], timeout: timeout)
68 | }
69 |
70 | func test_goToOrderDetails() {
71 | wait(for: [
72 | goToOrderDetails(status: .loggedOut, shouldSucceed: false, dataSourceShouldSucceed: false),
73 | goToOrderDetails(status: .loggedOut, shouldSucceed: true, dataSourceShouldSucceed: true),
74 | goToOrderDetails(status: .loggedIn, shouldSucceed: true, dataSourceShouldSucceed: true)
75 | ], timeout: timeout)
76 | }
77 |
78 | func test_goToSettings() {
79 | wait(for: [
80 | goToSettings(status: .loggedOut, shouldSucceed: true),
81 | goToSettings(status: .loggedIn, shouldSucceed: true)
82 | ], timeout: timeout)
83 | }
84 |
85 | func test_goToReorder() {
86 | wait(for: [
87 | goToReorder(status: .loggedOut, shouldSucceed: false, dataSourceShouldSucceed: false),
88 | goToReorder(status: .loggedOut, shouldSucceed: true, dataSourceShouldSucceed: true),
89 | goToReorder(status: .loggedIn, shouldSucceed: true, dataSourceShouldSucceed: true)
90 | ], timeout: timeout)
91 | }
92 | }
93 |
94 | extension NavigationIntentHandlerTests {
95 |
96 | fileprivate func goToHome(status: UserStatus, shouldSucceed: Bool) -> XCTestExpectation {
97 | setUp(userStatus: status)
98 | let expectation = self.expectation(description: #function)
99 | navigationIntentHandler.handleIntent(.goToHome).finally { future in
100 | XCTAssert(shouldSucceed ? future.hasResult() : future.hasError())
101 | expectation.fulfill()
102 | }
103 | return expectation
104 | }
105 |
106 | fileprivate func goToLogin(status: UserStatus, shouldSucceed: Bool) -> XCTestExpectation {
107 | setUp(userStatus: status)
108 | let expectation = self.expectation(description: #function)
109 | navigationIntentHandler.handleIntent(.goToLogin).finally { future in
110 | XCTAssert(shouldSucceed ? future.hasResult() : future.hasError())
111 | expectation.fulfill()
112 | }
113 | return expectation
114 | }
115 |
116 | fileprivate func goToResetPassword(status: UserStatus, shouldSucceed: Bool) -> XCTestExpectation {
117 | setUp(userStatus: status)
118 | let expectation = self.expectation(description: #function)
119 | navigationIntentHandler.handleIntent(.goToResetPassword("")).finally { future in
120 | XCTAssert(shouldSucceed ? future.hasResult() : future.hasError())
121 | expectation.fulfill()
122 | }
123 | return expectation
124 | }
125 |
126 | fileprivate func goToSearch(status: UserStatus, shouldSucceed: Bool) -> XCTestExpectation {
127 | setUp(userStatus: status)
128 | let expectation = self.expectation(description: #function)
129 | navigationIntentHandler.handleIntent(.goToSearch(nil, nil)).finally { future in
130 | XCTAssert(shouldSucceed ? future.hasResult() : future.hasError())
131 | expectation.fulfill()
132 | }
133 | return expectation
134 | }
135 |
136 | fileprivate func goToRestaurant(status: UserStatus, shouldSucceed: Bool) -> XCTestExpectation {
137 | setUp(userStatus: status)
138 | let expectation = self.expectation(description: #function)
139 | navigationIntentHandler.handleIntent(.goToRestaurant("", nil, nil, nil)).finally { future in
140 | XCTAssert(shouldSucceed ? future.hasResult() : future.hasError())
141 | expectation.fulfill()
142 | }
143 | return expectation
144 | }
145 |
146 | fileprivate func goToReorder(status: UserStatus, shouldSucceed: Bool, dataSourceShouldSucceed: Bool) -> XCTestExpectation {
147 | setUp(userStatus: status, dataSourceShouldSucceed: dataSourceShouldSucceed)
148 | let expectation = self.expectation(description: #function)
149 | navigationIntentHandler.handleIntent(.goToReorder("")).finally { future in
150 | XCTAssert(shouldSucceed ? future.hasResult() : future.hasError())
151 | expectation.fulfill()
152 | }
153 | return expectation
154 | }
155 |
156 | fileprivate func goToOrderHistory(status: UserStatus, shouldSucceed: Bool, dataSourceShouldSucceed: Bool) -> XCTestExpectation {
157 | setUp(userStatus: status, dataSourceShouldSucceed: dataSourceShouldSucceed)
158 | let expectation = self.expectation(description: #function)
159 | navigationIntentHandler.handleIntent(.goToOrderHistory).finally { future in
160 | XCTAssert(shouldSucceed ? future.hasResult() : future.hasError())
161 | expectation.fulfill()
162 | }
163 | return expectation
164 | }
165 |
166 | fileprivate func goToOrderDetails(status: UserStatus, shouldSucceed: Bool, dataSourceShouldSucceed: Bool) -> XCTestExpectation {
167 | setUp(userStatus: status, dataSourceShouldSucceed: dataSourceShouldSucceed)
168 | let expectation = self.expectation(description: #function)
169 | navigationIntentHandler.handleIntent(.goToOrderDetails("")).finally { future in
170 | XCTAssert(shouldSucceed ? future.hasResult() : future.hasError())
171 | expectation.fulfill()
172 | }
173 | return expectation
174 | }
175 |
176 | fileprivate func goToSettings(status: UserStatus, shouldSucceed: Bool) -> XCTestExpectation {
177 | setUp(userStatus: status)
178 | let expectation = self.expectation(description: #function)
179 | navigationIntentHandler.handleIntent(.goToSettings).finally { future in
180 | XCTAssert(shouldSucceed ? future.hasResult() : future.hasError())
181 | expectation.fulfill()
182 | }
183 | return expectation
184 | }
185 | }
186 |
--------------------------------------------------------------------------------
/NavigationEngineDemoTests/Utilities/MockFlowControllers.swift:
--------------------------------------------------------------------------------
1 | //
2 | // MockFlowControllers.swift
3 | // NavigationEngine_Example
4 | //
5 | // Created by Alberto De Bortoli on 23/11/2018.
6 | // Copyright © 2018 Just Eat. All rights reserved.
7 | //
8 |
9 | import UIKit
10 | import NavigationEngineDemo
11 | import Promis
12 |
13 | class MockRestaurantsFlowController: RestaurantsFlowControllerProtocol {
14 |
15 | fileprivate let navigationController: UINavigationController
16 |
17 | var checkoutFlowController: CheckoutFlowControllerProtocol!
18 | var parentFlowController: RootFlowControllerProtocol!
19 |
20 | init(with parentFlowController: RootFlowControllerProtocol, navigationController: UINavigationController) {
21 | self.parentFlowController = parentFlowController
22 | self.navigationController = navigationController
23 | checkoutFlowController = MockCheckoutFlowController(with: self, navigationController: UINavigationController())
24 | }
25 |
26 | @discardableResult
27 | func dismiss(animated: Bool) -> Future {
28 | return Future.future(withResult: true)
29 | }
30 |
31 | @discardableResult
32 | func goBackToRoot(animated: Bool) -> Future {
33 | return Future.future(withResult: true)
34 | }
35 |
36 | @discardableResult
37 | func goToSearchAnimated(postcode: Postcode?, cuisine: Cuisine?, animated: Bool) -> Future {
38 | return Future.future(withResult: true)
39 | }
40 |
41 | @discardableResult
42 | func goToRestaurant(restaurantId: RestaurantId, postcode: Postcode?, serviceType: ServiceType?, section: RestaurantSection?, animated: Bool) -> Future {
43 | return Future.future(withResult: true)
44 | }
45 |
46 | @discardableResult
47 | func goToBasket(reorderId: ReorderId, animated: Bool) -> Future {
48 | return Future.future(withResult: true)
49 | }
50 |
51 | @discardableResult
52 | func goToCheckout(animated: Bool) -> Future {
53 | return Future.future(withResult: true)
54 | }
55 | }
56 |
57 | class MockOrdersFlowController: OrdersFlowControllerProtocol {
58 |
59 | private let navigationController: UINavigationController
60 | var parentFlowController: RootFlowControllerProtocol!
61 |
62 | init(with parentFlowController: RootFlowControllerProtocol, navigationController: UINavigationController) {
63 | self.parentFlowController = parentFlowController
64 | self.navigationController = navigationController
65 | }
66 |
67 | @discardableResult
68 | func dismiss(animated: Bool) -> Future {
69 | return Future.future(withResult: true)
70 | }
71 |
72 | @discardableResult
73 | func goBackToRoot(animated: Bool) -> Future {
74 | return Future.future(withResult: true)
75 | }
76 |
77 | @discardableResult
78 | func goToOrder(orderId: OrderId, animated: Bool) -> Future {
79 | return Future.future(withResult: true)
80 | }
81 |
82 | @discardableResult
83 | func goToRestaurant(restaurantId: String) -> Future {
84 | return Future.future(withResult: true)
85 | }
86 |
87 | @discardableResult
88 | func leaveFlow(animated: Bool) -> Future {
89 | return Future.future(withResult: true)
90 | }
91 | }
92 |
93 | class MockCheckoutFlowController: CheckoutFlowControllerProtocol {
94 |
95 | private let navigationController: UINavigationController
96 | var parentFlowController: RestaurantsFlowControllerProtocol!
97 |
98 | init(with parentFlowController: RestaurantsFlowControllerProtocol, navigationController: UINavigationController) {
99 | self.parentFlowController = parentFlowController
100 | self.navigationController = navigationController
101 | }
102 |
103 | @discardableResult
104 | func beginFlow(from viewController: UIViewController, animated: Bool) -> Future {
105 | return Future.future(withResult: true)
106 | }
107 |
108 | @discardableResult
109 | func proceedToPayment(animated: Bool) -> Future {
110 | return Future.future(withResult: true)
111 | }
112 |
113 | @discardableResult
114 | func goToOrderConfirmation(orderId: String) -> Future {
115 | return Future.future(withResult: true)
116 | }
117 |
118 | @discardableResult
119 | func leaveFlow(animated: Bool) -> Future {
120 | return Future.future(withResult: true)
121 | }
122 |
123 | @discardableResult
124 | func leaveFlowAndGoBackToSERP() -> Future {
125 | return Future.future(withResult: true)
126 | }
127 | }
128 |
129 | class MockSettingsFlowController: SettingsFlowControllerProtocol {
130 |
131 | private let navigationController: UINavigationController
132 | var parentFlowController: RootFlowControllerProtocol!
133 |
134 | init(with parentFlowController: RootFlowControllerProtocol, navigationController: UINavigationController) {
135 | self.parentFlowController = parentFlowController
136 | self.navigationController = navigationController
137 | }
138 |
139 | @discardableResult
140 | func dismiss(animated: Bool) -> Future {
141 | return Future.future(withResult: true)
142 | }
143 |
144 | @discardableResult
145 | func goBackToRoot(animated: Bool) -> Future {
146 | return Future.future(withResult: true)
147 | }
148 | }
149 |
150 | class MockAccountFlowController: AccountFlowControllerProtocol {
151 |
152 | private let navigationController: UINavigationController
153 | var parentFlowController: RootFlowControllerProtocol!
154 |
155 | init(with parentFlowController: RootFlowControllerProtocol, navigationController: UINavigationController) {
156 | self.parentFlowController = parentFlowController
157 | self.navigationController = navigationController
158 | }
159 |
160 | @discardableResult
161 | func beginLoginFlow(from viewController: UIViewController, animated: Bool) -> Future {
162 | return Future.future(withResult: true)
163 | }
164 |
165 | @discardableResult
166 | func waitForUserToLogin(from viewController: UIViewController, animated: Bool) -> Future {
167 | return Future.future(withResult: true)
168 | }
169 |
170 | func beginResetPasswordFlow(from viewController: UIViewController, token: ResetPasswordToken, animated: Bool) -> Future {
171 | return Future.future(withResult: true)
172 | }
173 |
174 | @discardableResult
175 | func leaveFlow(animated: Bool) -> Future {
176 | return Future.future(withResult: true)
177 | }
178 | }
179 |
180 | class MockRootFlowController: RootFlowControllerProtocol {
181 |
182 | private let tabBarController = UITabBarController()
183 |
184 | var restaurantsFlowController: RestaurantsFlowControllerProtocol!
185 | var ordersFlowController: OrdersFlowControllerProtocol!
186 | var settingsFlowController: SettingsFlowControllerProtocol!
187 |
188 | var accountFlowController: AccountFlowControllerProtocol!
189 |
190 | func setup() {
191 | let takeawayNavigationController = UINavigationController()
192 | let restaurantsFlowController = MockRestaurantsFlowController(with: self, navigationController: takeawayNavigationController)
193 | self.restaurantsFlowController = restaurantsFlowController
194 |
195 | let ordersNavigationController = UINavigationController()
196 | let ordersFlowController = MockOrdersFlowController(with: self, navigationController: ordersNavigationController)
197 | self.ordersFlowController = ordersFlowController
198 |
199 | let settingsNavigationController = UINavigationController()
200 | let settingsFlowController = MockSettingsFlowController(with: self, navigationController: settingsNavigationController)
201 | self.settingsFlowController = settingsFlowController
202 | }
203 |
204 | func dismissAll(animated: Bool) -> Future {
205 | return Future.future(withResult: true)
206 | }
207 |
208 | func dismissAndPopToRootAll(animated: Bool) -> Future {
209 | return Future.future(withResult: true)
210 | }
211 |
212 | func goToLogin(animated: Bool) -> Future {
213 | return Future.future(withResult: true)
214 | }
215 |
216 | func goToResetPassword(token: ResetPasswordToken, animated: Bool) -> Future {
217 | return Future.future(withResult: true)
218 | }
219 |
220 | func goToRestaurantsSection() -> Future {
221 | return Future.future(withResult: true)
222 | }
223 |
224 | func goToOrdersSection() -> Future {
225 | return Future.future(withResult: true)
226 | }
227 |
228 | func goToSettingsSection() -> Future