Business OperationsConversational AI

Add Visual Voice Experiences to your SAP Mobile Applications

By May 17, 2019December 14th, 2023No Comments

Recently, we created a full visual voice experience for the SAP sample application provided through the SAP Cloud Platform SDK for iOS. We did this with the new integration from the Alan Voice AI platform. Here, we’ll go over the steps we took to create this visual voice experience. You can find the full source code of this application and the supporting Alan Visual Voice scripts here.

1. Download and Install the SAP Cloud Platform SDK for iOS

Head over to SAP’s Developer page and click on “SAP Cloud Platform SDK for iOS”. Click on the top link there to download the SAP Cloud Platform SDK for iOS. Add to your Applications folder and open the Application.

2. Create the SAP Sample Application

Now, open the SAP Cloud Platform SDK on your computer, click “Create new”, then click “Sample Application”. Then follow the steps to add your SAP account, Application details, and the name of your Xcode project. This will create an Xcode project with the Sample application.

Once this is done, open the Xcode project and take a look around. Build the project and you can see it’s an application with Suppliers, Categories, Products, and Ordering information.

Now let’s integrate with Alan.

3. Integrate the application with Alan Platform

Go to Alan Studio at https://studio.alan.app. If you don’t have an account, create one to get started.

Once you login, create a Project named “SAP”. Now, we’re just going to be integrating our SAP sample application with Alan. Later we will create the voice experience.

At the top of the screen, switch from “Development” to “Production”. Now open the “Embed  Code ” menu, then click on the “iOS” tab and review the steps.

Then, Download the iOS SDK Framework. Once you download go back to your Xcode project.In your Xcode project, create a new group named “Alan”. Drag and drop the iOS SDK Framework into this group.

Next, go to the “Embedded Binaries” section and add the SDK Framework. Make sure that you also have the framework in your project’s “Linked Frameworks and Libraries” section as well.

Now, we need to show a message asking for microphone access. To do this, go to the file info.plist. In the “Key” column, right click and select “Add Row”. For the name of the Key, input “NSMicrophoneDescription”. The value here will be the message that your users will see when they press on the Alan button for this first time in the application. For this, use the message “Alan needs microphone access to provide the voice experience for this application” or something similar.

Go back to the group you created earlier named “Alan,” right click it, and select “New File”. Select “Swift” as the filetype and name it “WindowUI+Alan”. All of the Alan button’s functions will be stored in this file, including the size, color styles, and voice states. You can find the code for this file here:

[code language="objc" collapse="true" title="WindowUI+Alan.swift"]
//
//  UIWindow+Alan.swift
//  MyDeliveries
//
//  Created by Sergey Yuryev on 22/04/2019.
//  Copyright © 2019 SAP. All rights reserved.
//

import UIKit
import AlanSDK

public final class ObjectAssociation<T: Any> {
    
    private let policy: objc_AssociationPolicy
    
    public init(policy: objc_AssociationPolicy = .OBJC_ASSOCIATION_RETAIN_NONATOMIC) {
        self.policy = policy
    }
    
    public subscript(index: AnyObject) -> T? {
        get { return objc_getAssociatedObject(index, Unmanaged.passUnretained(self).toOpaque()) as! T? }
        set { objc_setAssociatedObject(index, Unmanaged.passUnretained(self).toOpaque(), newValue, policy) }
    }
    
}

extension UIWindow {
    
    private static let associationAlanButton = ObjectAssociation<AlanButton>()
    private static let associationAlanText = ObjectAssociation<AlanText>()
    
    var alanButton: AlanButton? {
        get {
            return UIWindow.associationAlanButton[self]
        }
        set {
            UIWindow.associationAlanButton[self] = newValue
        }
    }
    
    var alanText: AlanText? {
        get {
            return UIWindow.associationAlanText[self]
        }
        set {
            UIWindow.associationAlanText[self] = newValue
        }
    }
    
    func moveAlanToFront() {
        if let button = self.alanButton {
            self.bringSubviewToFront(button)
        }
        if let text = self.alanText {
            self.bringSubviewToFront(text)
        }
    }
    
    func addAlan() {
        let buttonSpace: CGFloat = 20
        let buttonWidth: CGFloat = 64
        let buttonHeight: CGFloat = 64
        let textWidth: CGFloat = self.frame.maxX - buttonWidth - buttonSpace * 3
        let textHeight: CGFloat = 64
        
        let config = AlanConfig(key: "", isButtonDraggable: false)

        self.alanButton = AlanButton(config: config)
        if let button = self.alanButton {
            let safeHeight = self.frame.maxY - self.safeAreaLayoutGuide.layoutFrame.maxY
            let realX = self.frame.maxX - buttonWidth - buttonSpace
            let realY = self.frame.maxY - safeHeight - buttonHeight - buttonSpace
            
            button.frame = CGRect(x: realX, y: realY, width: buttonWidth, height: buttonHeight)
            self.addSubview(button)
            self.bringSubviewToFront(button)
        }
        
        self.alanText = AlanText(frame: CGRect.zero)
        if let text = self.alanText {
            let safeHeight = self.frame.maxY - self.safeAreaLayoutGuide.layoutFrame.maxY
            let realX = self.frame.minX + buttonSpace
            let realY = self.frame.maxY - safeHeight - textHeight - buttonSpace
            
            text.frame = CGRect(x: realX, y: realY, width: textWidth, height: textHeight)
            self.addSubview(text)
            self.bringSubviewToFront(text)
            
            text.layer.shadowColor = UIColor.black.cgColor
            text.layer.shadowOffset = CGSize(width: 0, height: 0)
            text.layer.shadowOpacity = 0.3
            text.layer.shadowRadius = 4.0
            
            for subview in text.subviews {
                if let s = subview as? UILabel {
                    s.backgroundColor = UIColor.white
                }
            }
        }
    }
}
[/code]

The next thing to do is to open the projects “ApplicationUIManager.swift” file and add a few methods required to use the voice button in the application. Here are the sections that each method should be added to:

[code language="objc" collapse="true" title="ApplicationUIManager.swift" highlight="28,92"]
//
// AlanDeliveries
//
// Created by SAP Cloud Platform SDK for iOS Assistant application on 24/04/19
//

import SAPCommon
import SAPFiori
import SAPFioriFlows
import SAPFoundation

class SnapshotViewController: UIViewController {}

class ApplicationUIManager: ApplicationUIManaging {
    // MARK: – Properties

    let window: UIWindow

    /// Save ViewController while splash/onboarding screens are presented
    private var _savedApplicationRootViewController: UIViewController?
    private var _onboardingSplashViewController: (UIViewController & InfoTextSettable)?
    private var _coveringViewController: UIViewController?

    // MARK: – Init

    public init(window: UIWindow) {
        self.window = window
        self.window.addAlan()
    }

    // MARK: - ApplicationUIManaging

    func hideApplicationScreen(completionHandler: @escaping (Error?) -> Void) {
        // Check whether the covering screen is already presented or not
        guard self._coveringViewController == nil else {
            completionHandler(nil)
            return
        }

        self.saveApplicationScreenIfNecessary()
        self._coveringViewController = SnapshotViewController()
        self.window.rootViewController = self._coveringViewController

        completionHandler(nil)
    }

    func showSplashScreenForOnboarding(completionHandler: @escaping (Error?) -> Void) {
        // splash already presented
        guard self._onboardingSplashViewController == nil else {
            completionHandler(nil)
            return
        }

        setupSplashScreen()

        completionHandler(nil)
    }

    func showSplashScreenForUnlock(completionHandler: @escaping (Error?) -> Void) {
        guard self._onboardingSplashViewController == nil else {
            completionHandler(nil)
            return
        }

        self.saveApplicationScreenIfNecessary()

        setupSplashScreen()

        completionHandler(nil)
    }

    func showApplicationScreen(completionHandler: @escaping (Error?) -> Void) {
        // Check if an application screen has already been presented
        guard self.isSplashPresented else {
            completionHandler(nil)
            return
        }

        // Restore the saved application screen or create a new one
        let appViewController: UIViewController
        if let savedViewController = self._savedApplicationRootViewController {
            appViewController = savedViewController
        } else {
            let appDelegate = (UIApplication.shared.delegate as! AppDelegate)
            let splitViewController = UIStoryboard(name: "Main", bundle: Bundle.main).instantiateViewController(withIdentifier: "MainSplitViewController") as! UISplitViewController
            splitViewController.delegate = appDelegate
            splitViewController.modalPresentationStyle = .currentContext
            splitViewController.preferredDisplayMode = .allVisible
            appViewController = splitViewController
        }
        self.window.rootViewController = appViewController
        self.window.moveAlanToFront()
        self._onboardingSplashViewController = nil
        self._savedApplicationRootViewController = nil
        self._coveringViewController = nil

        completionHandler(nil)
    }

    func releaseRootFromMemory() {
        self._savedApplicationRootViewController = nil
    }

    // MARK: – Helpers

    private var isSplashPresented: Bool {
        return self.window.rootViewController is FUIInfoViewController || self.window.rootViewController is SnapshotViewController
    }

    /// Helper method to capture the real application screen.
    private func saveApplicationScreenIfNecessary() {
        if self._savedApplicationRootViewController == nil, !self.isSplashPresented {
            self._savedApplicationRootViewController = self.window.rootViewController
        }
    }

    private func setupSplashScreen() {
        self._onboardingSplashViewController = FUIInfoViewController.createSplashScreenInstanceFromStoryboard()
        self.window.rootViewController = self._onboardingSplashViewController

        // Set the splash screen for the specific presenter
        let modalPresenter = OnboardingFlowProvider.modalUIViewControllerPresenter
        modalPresenter.setSplashScreen(self._onboardingSplashViewController!)
        modalPresenter.animated = true
    }
}
[/code]

For the final step of the integration, return to your project in Alan Studio, open the “Embed  Code ” menu, “iOS” tab, and copy the “Alan SDK Key”. Make sure you copy the “Production” key for this step!

Now go back to your Xcode project’s “WindowUI+Alan.swift” file. Paste key into the quotes between the quotes in the line let config = AlanConfig(key: ””, isButtonDraggable: false)

It’s time to Build the application to see how it looks. Press the big Play button in the upper left of Xcode.

See the Alan button in the bottom right of the application. Now it’s time to create the full Visual Voice experience for the application.

4. Create the Visual Voice experience in Alan

The Visual Voice experience for this application will let users ask about products, orders, and suppliers. We’ve already created the scripts for this, which you can find here. Take these scripts and copy and paste them into your project within Alan and save. You’ll want to create a new version with this script and put it on “Production”.

Now that that’s done, we need to add handlers in the application which will control the application with voice commands. Note that the handlers for your application will be slightly different. Here are examples of our handlers:

[code language="objc" collapse="true" title="WindowUI+Alan.swift" highlight="27-36,40,41,45-61,90-113,157,160-228"]
//
//  UIWindow+Alan.swift
//  MyDeliveries
//
//  Created by Sergey Yuryev on 22/04/2019.
//  Copyright © 2019 SAP. All rights reserved.
//

import UIKit
import AlanSDK

public final class ObjectAssociation<T: Any> {
    
    private let policy: objc_AssociationPolicy
    
    public init(policy: objc_AssociationPolicy = .OBJC_ASSOCIATION_RETAIN_NONATOMIC) {
        self.policy = policy
    }
    
    public subscript(index: AnyObject) -> T? {
        get { return objc_getAssociatedObject(index, Unmanaged.passUnretained(self).toOpaque()) as! T? }
        set { objc_setAssociatedObject(index, Unmanaged.passUnretained(self).toOpaque(), newValue, policy) }
    }
    
}

protocol ProductViewDelegate {
    func highlightProductId(_ id: String?)
    func showProductCategory(_ category: String)
    func showProductIds(_ ids: [String])
}

protocol NavigateViewDelegate {
    func navigateCategory(_ category: String)
    func navigateBack()
}

extension UIWindow {
    
    private static let navigateDelegate = ObjectAssociation<NavigateViewDelegate>()
    private static let productDelegate = ObjectAssociation<ProductViewDelegate>()
    private static let associationAlanButton = ObjectAssociation<AlanButton>()
    private static let associationAlanText = ObjectAssociation<AlanText>()
    
    var navigateViewDelegate: NavigateViewDelegate? {
        get {
            return UIWindow.navigateDelegate[self]
        }
        set {
            UIWindow.navigateDelegate[self] = newValue
        }
    }
    
    var productViewDelegate: ProductViewDelegate? {
        get {
            return UIWindow.productDelegate[self]
        }
        set {
            UIWindow.productDelegate[self] = newValue
        }
    }
    
    var alanButton: AlanButton? {
        get {
            return UIWindow.associationAlanButton[self]
        }
        set {
            UIWindow.associationAlanButton[self] = newValue
        }
    }
    
    var alanText: AlanText? {
        get {
            return UIWindow.associationAlanText[self]
        }
        set {
            UIWindow.associationAlanText[self] = newValue
        }
    }
    
    func moveAlanToFront() {
        if let button = self.alanButton {
            self.bringSubviewToFront(button)
        }
        if let text = self.alanText {
            self.bringSubviewToFront(text)
        }
    }
    
    func setVisual(_ data: [String: Any]) {
        print("setVisual: \(data)");
        if let button = self.alanButton {
            button.setVisual(data)
        }
    }
    
    func playText(_ text: String) {
        if let button = self.alanButton {
            button.playText(text)
        }
    }
    
    func playData(_ data: [String: String]) {
        if let button = self.alanButton {
            button.playData(data)
        }
    }
    
    func call(method: String, params: [String: Any], callback:@escaping ((Error?, String?) -> Void)) {
        if let button = self.alanButton {
            button.call(method, withParams: params, callback: callback)
        }
    }
    
    func addAlan() {
        let buttonSpace: CGFloat = 20
        let buttonWidth: CGFloat = 64
        let buttonHeight: CGFloat = 64
        let textWidth: CGFloat = self.frame.maxX - buttonWidth - buttonSpace * 3
        let textHeight: CGFloat = 64
        
        let config = AlanConfig(key: "", isButtonDraggable: false)

        self.alanButton = AlanButton(config: config)
        if let button = self.alanButton {
            let safeHeight = self.frame.maxY - self.safeAreaLayoutGuide.layoutFrame.maxY
            let realX = self.frame.maxX - buttonWidth - buttonSpace
            let realY = self.frame.maxY - safeHeight - buttonHeight - buttonSpace
            
            button.frame = CGRect(x: realX, y: realY, width: buttonWidth, height: buttonHeight)
            self.addSubview(button)
            self.bringSubviewToFront(button)
        }
        
        self.alanText = AlanText(frame: CGRect.zero)
        if let text = self.alanText {
            let safeHeight = self.frame.maxY - self.safeAreaLayoutGuide.layoutFrame.maxY
            let realX = self.frame.minX + buttonSpace
            let realY = self.frame.maxY - safeHeight - textHeight - buttonSpace
            
            text.frame = CGRect(x: realX, y: realY, width: textWidth, height: textHeight)
            self.addSubview(text)
            self.bringSubviewToFront(text)
            
            text.layer.shadowColor = UIColor.black.cgColor
            text.layer.shadowOffset = CGSize(width: 0, height: 0)
            text.layer.shadowOpacity = 0.3
            text.layer.shadowRadius = 4.0
            
            for subview in text.subviews {
                if let s = subview as? UILabel {
                    s.backgroundColor = UIColor.white
                }
            }
        }
        
        NotificationCenter.default.addObserver(self, selector: #selector(self.handleEvent(_:)), name:NSNotification.Name(rawValue: "kAlanSDKEventNotification"), object:nil)
    }
    
    @objc func handleEvent(_ notification: Notification) {
        guard let userInfo = notification.userInfo else {
            return
        }
        guard let event = userInfo["onEvent"] as? String else {
            return
        }
        guard event == "command" else {
            return
        }
        guard let jsonString = userInfo["jsonString"] as? String else {
            return
        }
        guard let data = jsonString.data(using: .utf8) else {
            return
        }
        guard let unwrapped = try? JSONSerialization.jsonObject(with: data, options: [])  else {
            return
        }
        guard let d = unwrapped as? [String: Any] else {
            return
        }
        guard let json = d["data"] as? [String: Any] else {
            return
        }
        guard let command = json["command"] as? String else {
            return
        }
        
        if command == "showProductCategory" {
            if let value = json["value"] as? String {
                if let d = self.productViewDelegate {
                    d.showProductCategory(value)
                }
            }
        }
        else if command == "showProductIds" {
            if let value = json["value"] as? [String] {
                if let d = self.productViewDelegate {
                    d.showProductIds(value)
                }
            }
        }
        else if command == "highlightProductId" {
            if let value = json["value"] as? String {
                if let d = self.productViewDelegate {
                    d.highlightProductId(value)
                }
            }
            else {
                if let d = self.productViewDelegate {
                    d.highlightProductId(nil)
                }
            }
        }
        else if command == "navigate" {
            if let value = json["screen"] as? String {
                if let d = self.navigateViewDelegate {
                    d.navigateCategory(value)
                }
            }
        }
        else if command == "goBack" {
            if let d = self.navigateViewDelegate {
                d.navigateBack()
            }
        }
    }
}
[/code]
[code language="objc" collapse="true" title="ProductMasterViewController.swift" highlight="13,25,29-31,36-39,115-165"]
//
// AlanDeliveries
//
// Created by SAP Cloud Platform SDK for iOS Assistant application on 24/04/19
//

import Foundation
import SAPCommon
import SAPFiori
import SAPFoundation
import SAPOData

class ProductMasterViewController: FUIFormTableViewController, SAPFioriLoadingIndicator, ProductViewDelegate {
    var espmContainer: ESPMContainer<OnlineODataProvider>!
    public var loadEntitiesBlock: ((_ completionHandler: @escaping ([Product]?, Error?) -> Void) -> Void)?
    private var entities: [Product] = [Product]()
    private var allEntities: [Product] = [Product]()
    private var entityImages = [Int: UIImage]()
    private let logger = Logger.shared(named: "ProductMasterViewControllerLogger")
    private let okTitle = NSLocalizedString("keyOkButtonTitle",
                                            value: "OK",
                                            comment: "XBUT: Title of OK button.")
    var loadingIndicator: FUILoadingIndicatorView?

    var highlightedId: String?
    
    override func viewWillDisappear(_ animated: Bool) {
        super.viewWillDisappear(animated)
        if let window = UIApplication.shared.keyWindow {
            window.productViewDelegate = nil
        }
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        if let window = UIApplication.shared.keyWindow {
            window.setVisual(["screen": "Product"])
            window.productViewDelegate = self
        }
    }
    
    override func viewDidLoad() {
        super.viewDidLoad()
        self.edgesForExtendedLayout = []
        // Add refreshcontrol UI
        self.refreshControl?.addTarget(self, action: #selector(self.refresh), for: UIControl.Event.valueChanged)
        self.tableView.addSubview(self.refreshControl!)
        // Cell height settings
        self.tableView.rowHeight = UITableView.automaticDimension
        self.tableView.estimatedRowHeight = 98
        self.updateTable()
    }

    var preventNavigationLoop = false
    var entitySetName: String?

    override func viewWillAppear(_ animated: Bool) {
        super.viewWillAppear(animated)
        self.clearsSelectionOnViewWillAppear = self.splitViewController!.isCollapsed
    }

    override func didReceiveMemoryWarning() {
        super.didReceiveMemoryWarning()
        // Dispose of any resources that can be recreated.
    }

    // MARK: - Table view data source

    override func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
        return self.entities.count
    }

    override func tableView(_: UITableView, canEditRowAt _: IndexPath) -> Bool {
        return true
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let product = self.entities[indexPath.row]
        let cell = CellCreationHelper.objectCellWithNonEditableContent(tableView: tableView, indexPath: indexPath, key: "ProductId", value: "\(product.productID!)")
        cell.preserveDetailImageSpacing = true
        cell.headlineText = product.name
        cell.footnoteText = product.productID
        let backgroundView = UIView()
        backgroundView.backgroundColor = UIColor.white
        
        if let image = image(for: indexPath, product: product) {
            cell.detailImage = image
            cell.detailImageView.contentMode = .scaleAspectFit
        }
        if let hid = self.highlightedId, let current = product.productID, hid == current {
            backgroundView.backgroundColor = UIColor(red: 235 / 255, green: 245 / 255, blue: 255 / 255, alpha: 1.0)
        }
        cell.backgroundView = backgroundView
        return cell
    }

    override func tableView(_ tableView: UITableView, commit editingStyle: UITableViewCell.EditingStyle, forRowAt indexPath: IndexPath) {
        if editingStyle != .delete {
            return
        }
        let currentEntity = self.entities[indexPath.row]
        self.espmContainer.deleteEntity(currentEntity) { error in
            if let error = error {
                self.logger.error("Delete entry failed.", error: error)
                AlertHelper.displayAlert(with: NSLocalizedString("keyErrorDeletingEntryTitle", value: "Delete entry failed", comment: "XTIT: Title of deleting entry error pop up."), error: error, viewController: self)
            } else {
                self.entities.remove(at: indexPath.row)
                tableView.deleteRows(at: [indexPath], with: .fade)
            }
        }
    }

    // MARK: - Data accessing
    
    func highlightProductId(_ id: String?) {
        self.highlightedId = id
        DispatchQueue.main.async {
            self.tableView.reloadData()
            self.logger.info("Alan: Table updated successfully!")
        }
    }

    internal func showProductCategory(_ category: String) {
        if category == "All" {
            self.entityImages.removeAll()
            self.entities.removeAll()
            self.entities.append(contentsOf: self.allEntities)
        }
        else {
            let filtered = self.allEntities.filter {
                if let c = $0.category, c == category {
                    return true
                }
                return false
            }
            self.entityImages.removeAll()
            self.entities.removeAll()
            self.entities.append(contentsOf: filtered)
        }
        DispatchQueue.main.async {
            let range = NSMakeRange(0, self.tableView.numberOfSections)
            let sections = NSIndexSet(indexesIn: range)
            self.tableView.reloadSections(sections as IndexSet, with: .automatic)
            self.logger.info("Alan: Table updated successfully!")
        }
        
    }
    
    internal func showProductIds(_ ids: [String]) {
        let filtered = self.allEntities.filter {
            if let productId = $0.productID, ids.contains(productId) {
                return true
            }
            return false
        }
        self.entityImages.removeAll()
        self.entities.removeAll()
        self.entities.append(contentsOf: filtered)
        DispatchQueue.main.async {
            let range = NSMakeRange(0, self.tableView.numberOfSections)
            let sections = NSIndexSet(indexesIn: range)
            self.tableView.reloadSections(sections as IndexSet, with: .automatic)
            self.logger.info("Alan: Table updated successfully!")
        }
    }
    
    
    func requestEntities(completionHandler: @escaping (Error?) -> Void) {
        self.loadEntitiesBlock!() { entities, error in
            if let error = error {
                completionHandler(error)
                return
            }
            
            self.entities = entities!
            self.allEntities.append(contentsOf: entities!)
            
            let encoder = JSONEncoder()
            if let encodedEntityValue = try? encoder.encode(self.entities) {
                if let json = String(data: encodedEntityValue, encoding: .utf8) {
                    print(json)
                    if let window = UIApplication.shared.keyWindow {
                        window.call(method: "script::updateProductEntities", params: ["json": json] , callback: { (error, result) in
                        })
                    }
                }
            }
            
            completionHandler(nil)
        }
    }

    // MARK: - Segues

    override func prepare(for segue: UIStoryboardSegue, sender _: Any?) {
        if segue.identifier == "showDetail" {
            // Show the selected Entity on the Detail view
            guard let indexPath = self.tableView.indexPathForSelectedRow else {
                return
            }
            self.logger.info("Showing details of the chosen element.")
            let selectedEntity = self.entities[indexPath.row]
            let detailViewController = segue.destination as! ProductDetailViewController
            detailViewController.entity = selectedEntity
            detailViewController.navigationItem.leftItemsSupplementBackButton = true
            detailViewController.navigationItem.title = self.entities[(self.tableView.indexPathForSelectedRow?.row)!].productID ?? ""
            detailViewController.allowsEditableCells = false
            detailViewController.tableUpdater = self
            detailViewController.preventNavigationLoop = self.preventNavigationLoop
            detailViewController.espmContainer = self.espmContainer
            detailViewController.entitySetName = self.entitySetName
        } else if segue.identifier == "addEntity" {
            // Show the Detail view with a new Entity, which can be filled to create on the server
            self.logger.info("Showing view to add new entity.")
            let dest = segue.destination as! UINavigationController
            let detailViewController = dest.viewControllers[0] as! ProductDetailViewController
            detailViewController.title = NSLocalizedString("keyAddEntityTitle", value: "Add Entity", comment: "XTIT: Title of add new entity screen.")
            let doneButton = UIBarButtonItem(barButtonSystemItem: .done, target: detailViewController, action: #selector(detailViewController.createEntity))
            detailViewController.navigationItem.rightBarButtonItem = doneButton
            let cancelButton = UIBarButtonItem(title: NSLocalizedString("keyCancelButtonToGoPreviousScreen", value: "Cancel", comment: "XBUT: Title of Cancel button."), style: .plain, target: detailViewController, action: #selector(detailViewController.cancel))
            detailViewController.navigationItem.leftBarButtonItem = cancelButton
            detailViewController.allowsEditableCells = true
            detailViewController.tableUpdater = self
            detailViewController.espmContainer = self.espmContainer
            detailViewController.entitySetName = self.entitySetName
        }
    }

    // MARK: - Image loading

    private func image(for indexPath: IndexPath, product: Product) -> UIImage? {
        if let image = self.entityImages[indexPath.row] {
            return image
        } else {
            espmContainer.downloadMedia(entity: product, completionHandler: { data, error in
                if let error = error {
                    self.logger.error("Download media failed. Error: \(error)", error: error)
                    return
                }
                guard let data = data else {
                    self.logger.info("Media data is empty.")
                    return
                }
                if let image = UIImage(data: data) {
                    // store the downloaded image
                    self.entityImages[indexPath.row] = image
                    // update the cell
                    DispatchQueue.main.async {
                        self.tableView.beginUpdates()
                        if let cell = self.tableView.cellForRow(at: indexPath) as? FUIObjectTableViewCell {
                            cell.detailImage = image
                            cell.detailImageView.contentMode = .scaleAspectFit
                        }
                        self.tableView.endUpdates()
                    }
                }
            })
            return nil
        }
    }

    // MARK: - Table update

    func updateTable() {
        self.showFioriLoadingIndicator()
        DispatchQueue.global().async {
            self.loadData {
                self.hideFioriLoadingIndicator()
            }
        }
    }

    private func loadData(completionHandler: @escaping () -> Void) {
        self.requestEntities { error in
            defer {
                completionHandler()
            }
            if let error = error {
                AlertHelper.displayAlert(with: NSLocalizedString("keyErrorLoadingData", value: "Loading data failed!", comment: "XTIT: Title of loading data error pop up."), error: error, viewController: self)
                self.logger.error("Could not update table. Error: \(error)", error: error)
                return
            }
            DispatchQueue.main.async {
                self.tableView.reloadData()
                self.logger.info("Table updated successfully!")
            }
        }
    }

    @objc func refresh() {
        DispatchQueue.global().async {
            self.loadData {
                DispatchQueue.main.async {
                    self.refreshControl?.endRefreshing()
                }
            }
        }
    }
}

extension ProductMasterViewController: EntitySetUpdaterDelegate {
    func entitySetHasChanged() {
        self.updateTable()
    }
}
[/code]
[code language="objc" collapse="true" title="CollectionsViewController.swift" highlight="20,36-91,107-110"]
//
// AlanDeliveries
//
// Created by SAP Cloud Platform SDK for iOS Assistant application on 24/04/19
//

import Foundation
import SAPFiori
import SAPFioriFlows
import SAPOData

protocol EntityUpdaterDelegate {
    func entityHasChanged(_ entity: EntityValue?)
}

protocol EntitySetUpdaterDelegate {
    func entitySetHasChanged()
}

class CollectionsViewController: FUIFormTableViewController, NavigateViewDelegate {
    private var collections = CollectionType.all

    // Variable to store the selected index path
    private var selectedIndex: IndexPath?

    private let okTitle = NSLocalizedString("keyOkButtonTitle",
                                            value: "OK",
                                            comment: "XBUT: Title of OK button.")

    var isPresentedInSplitView: Bool {
        return !(self.splitViewController?.isCollapsed ?? true)
    }

    // Navigate
    
    func navigateBack() {
        DispatchQueue.main.async {
            if let navigation1 = self.splitViewController?.viewControllers.last as? UINavigationController {
                if let navigation2 = navigation1.viewControllers.last as? UINavigationController {
                    if navigation2.viewControllers.count < 2 {
                        navigation1.popViewController(animated: true)
                    }
                    else {
                        if let last = navigation2.viewControllers.last {
                            last.navigationController?.popViewController(animated: true)
                        }
                    }
                }
            }
        }
    }
    
    func navigateCategory(_ category: String) {
        var indexPath = IndexPath(row: 0, section: 0)
        if( category == "Sales") {
            indexPath = IndexPath(row: 6, section: 0)
        }
        else if( category == "PurchaseOrderItems") {
            indexPath = IndexPath(row: 3, section: 0)
        }
        else if( category == "ProductText") {
            indexPath = IndexPath(row: 2, section: 0)
        }
        else if( category == "PurchaseOrderHeaders") {
            indexPath = IndexPath(row: 4, section: 0)
        }
        else if( category == "Supplier") {
            indexPath = IndexPath(row: 0, section: 0)
        }
        else if( category == "Product") {
            indexPath = IndexPath(row: 9, section: 0)
        }
        else if( category == "Stock") {
            indexPath = IndexPath(row: 5, section: 0)
        }
        else if( category == "ProductCategory") {
            indexPath = IndexPath(row: 1, section: 0)
        }
        else if( category == "SalesOrder") {
            indexPath = IndexPath(row: 8, section: 0)
        }
        else if( category == "Customer") {
            indexPath = IndexPath(row: 7, section: 0)
        }
        DispatchQueue.main.async {
            self.navigationController?.popToRootViewController(animated: true)
            DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
                self.collectionSelected(at: indexPath)
            }
        }
    }
    
    // MARK: - Lifecycle

    override func viewDidLoad() {
        super.viewDidLoad()
        self.preferredContentSize = CGSize(width: 320, height: 480)

        self.tableView.rowHeight = UITableView.automaticDimension
        self.tableView.estimatedRowHeight = 44
    }

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        self.makeSelection()
        
        if let window = UIApplication.shared.keyWindow {
            window.setVisual(["screen": "Main"])
            window.navigateViewDelegate = self
        }
    }

    override func viewWillTransition(to _: CGSize, with coordinator: UIViewControllerTransitionCoordinator) {
        coordinator.animate(alongsideTransition: nil, completion: { _ in
            let isNotInSplitView = !self.isPresentedInSplitView
            self.tableView.visibleCells.forEach { cell in
                // To refresh the disclosure indicator of each cell
                cell.accessoryType = isNotInSplitView ? .disclosureIndicator : .none
            }
            self.makeSelection()
        })
    }

    // MARK: - UITableViewDelegate

    override func numberOfSections(in _: UITableView) -> Int {
        return 1
    }

    override func tableView(_: UITableView, numberOfRowsInSection _: Int) -> Int {
        return collections.count
    }

    override func tableView(_: UITableView, heightForRowAt _: IndexPath) -> CGFloat {
        return 44
    }

    override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        let cell = tableView.dequeueReusableCell(withIdentifier: FUIObjectTableViewCell.reuseIdentifier, for: indexPath) as! FUIObjectTableViewCell
        cell.headlineLabel.text = self.collections[indexPath.row].rawValue
        cell.accessoryType = !self.isPresentedInSplitView ? .disclosureIndicator : .none
        cell.isMomentarySelection = false
        return cell
    }

    override func tableView(_: UITableView, didSelectRowAt indexPath: IndexPath) {
        self.collectionSelected(at: indexPath)
    }

    // CollectionType selection helper
    private func collectionSelected(at indexPath: IndexPath) {
        // Load the EntityType specific ViewController from the specific storyboard"
        var masterViewController: UIViewController!
        guard let espmContainer = OnboardingSessionManager.shared.onboardingSession?.odataController.espmContainer else {
            AlertHelper.displayAlert(with: "OData service is not reachable, please onboard again.", error: nil, viewController: self)
            return
        }
        self.selectedIndex = indexPath

        switch self.collections[indexPath.row] {
        case .suppliers:
            let supplierStoryBoard = UIStoryboard(name: "Supplier", bundle: nil)
            let supplierMasterViewController = supplierStoryBoard.instantiateViewController(withIdentifier: "SupplierMaster") as! SupplierMasterViewController
            supplierMasterViewController.espmContainer = espmContainer
            supplierMasterViewController.entitySetName = "Suppliers"
            func fetchSuppliers(_ completionHandler: @escaping ([Supplier]?, Error?) -> Void) {
                // Only request the first 20 values. If you want to modify the requested entities, you can do it here.
                let query = DataQuery().selectAll().top(20)
                do {
                    espmContainer.fetchSuppliers(matching: query, completionHandler: completionHandler)
                }
            }
            supplierMasterViewController.loadEntitiesBlock = fetchSuppliers
            supplierMasterViewController.navigationItem.title = "Supplier"
            masterViewController = supplierMasterViewController
        case .productCategories:
            let productCategoryStoryBoard = UIStoryboard(name: "ProductCategory", bundle: nil)
            let productCategoryMasterViewController = productCategoryStoryBoard.instantiateViewController(withIdentifier: "ProductCategoryMaster") as! ProductCategoryMasterViewController
            productCategoryMasterViewController.espmContainer = espmContainer
            productCategoryMasterViewController.entitySetName = "ProductCategories"
            func fetchProductCategories(_ completionHandler: @escaping ([ProductCategory]?, Error?) -> Void) {
                // Only request the first 20 values. If you want to modify the requested entities, you can do it here.
                let query = DataQuery().selectAll().top(20)
                do {
                    espmContainer.fetchProductCategories(matching: query, completionHandler: completionHandler)
                }
            }
            productCategoryMasterViewController.loadEntitiesBlock = fetchProductCategories
            productCategoryMasterViewController.navigationItem.title = "ProductCategory"
            masterViewController = productCategoryMasterViewController
        case .productTexts:
            let productTextStoryBoard = UIStoryboard(name: "ProductText", bundle: nil)
            let productTextMasterViewController = productTextStoryBoard.instantiateViewController(withIdentifier: "ProductTextMaster") as! ProductTextMasterViewController
            productTextMasterViewController.espmContainer = espmContainer
            productTextMasterViewController.entitySetName = "ProductTexts"
            func fetchProductTexts(_ completionHandler: @escaping ([ProductText]?, Error?) -> Void) {
                // Only request the first 20 values. If you want to modify the requested entities, you can do it here.
                let query = DataQuery().selectAll().top(20)
                do {
                    espmContainer.fetchProductTexts(matching: query, completionHandler: completionHandler)
                }
            }
            productTextMasterViewController.loadEntitiesBlock = fetchProductTexts
            productTextMasterViewController.navigationItem.title = "ProductText"
            masterViewController = productTextMasterViewController
        case .purchaseOrderItems:
            let purchaseOrderItemStoryBoard = UIStoryboard(name: "PurchaseOrderItem", bundle: nil)
            let purchaseOrderItemMasterViewController = purchaseOrderItemStoryBoard.instantiateViewController(withIdentifier: "PurchaseOrderItemMaster") as! PurchaseOrderItemMasterViewController
            purchaseOrderItemMasterViewController.espmContainer = espmContainer
            purchaseOrderItemMasterViewController.entitySetName = "PurchaseOrderItems"
            func fetchPurchaseOrderItems(_ completionHandler: @escaping ([PurchaseOrderItem]?, Error?) -> Void) {
                // Only request the first 20 values. If you want to modify the requested entities, you can do it here.
                let query = DataQuery().selectAll().top(20)
                do {
                    espmContainer.fetchPurchaseOrderItems(matching: query, completionHandler: completionHandler)
                }
            }
            purchaseOrderItemMasterViewController.loadEntitiesBlock = fetchPurchaseOrderItems
            purchaseOrderItemMasterViewController.navigationItem.title = "PurchaseOrderItem"
            masterViewController = purchaseOrderItemMasterViewController
        case .purchaseOrderHeaders:
            let purchaseOrderHeaderStoryBoard = UIStoryboard(name: "PurchaseOrderHeader", bundle: nil)
            let purchaseOrderHeaderMasterViewController = purchaseOrderHeaderStoryBoard.instantiateViewController(withIdentifier: "PurchaseOrderHeaderMaster") as! PurchaseOrderHeaderMasterViewController
            purchaseOrderHeaderMasterViewController.espmContainer = espmContainer
            purchaseOrderHeaderMasterViewController.entitySetName = "PurchaseOrderHeaders"
            func fetchPurchaseOrderHeaders(_ completionHandler: @escaping ([PurchaseOrderHeader]?, Error?) -> Void) {
                // Only request the first 20 values. If you want to modify the requested entities, you can do it here.
                let query = DataQuery().selectAll().top(20)
                do {
                    espmContainer.fetchPurchaseOrderHeaders(matching: query, completionHandler: completionHandler)
                }
            }
            purchaseOrderHeaderMasterViewController.loadEntitiesBlock = fetchPurchaseOrderHeaders
            purchaseOrderHeaderMasterViewController.navigationItem.title = "PurchaseOrderHeader"
            masterViewController = purchaseOrderHeaderMasterViewController
        case .stock:
            let stockStoryBoard = UIStoryboard(name: "Stock", bundle: nil)
            let stockMasterViewController = stockStoryBoard.instantiateViewController(withIdentifier: "StockMaster") as! StockMasterViewController
            stockMasterViewController.espmContainer = espmContainer
            stockMasterViewController.entitySetName = "Stock"
            func fetchStock(_ completionHandler: @escaping ([Stock]?, Error?) -> Void) {
                // Only request the first 20 values. If you want to modify the requested entities, you can do it here.
                let query = DataQuery().selectAll().top(20)
                do {
                    espmContainer.fetchStock(matching: query, completionHandler: completionHandler)
                }
            }
            stockMasterViewController.loadEntitiesBlock = fetchStock
            stockMasterViewController.navigationItem.title = "Stock"
            masterViewController = stockMasterViewController
        case .salesOrderItems:
            let salesOrderItemStoryBoard = UIStoryboard(name: "SalesOrderItem", bundle: nil)
            let salesOrderItemMasterViewController = salesOrderItemStoryBoard.instantiateViewController(withIdentifier: "SalesOrderItemMaster") as! SalesOrderItemMasterViewController
            salesOrderItemMasterViewController.espmContainer = espmContainer
            salesOrderItemMasterViewController.entitySetName = "SalesOrderItems"
            func fetchSalesOrderItems(_ completionHandler: @escaping ([SalesOrderItem]?, Error?) -> Void) {
                // Only request the first 20 values. If you want to modify the requested entities, you can do it here.
                let query = DataQuery().selectAll().top(20)
                do {
                    espmContainer.fetchSalesOrderItems(matching: query, completionHandler: completionHandler)
                }
            }
            salesOrderItemMasterViewController.loadEntitiesBlock = fetchSalesOrderItems
            salesOrderItemMasterViewController.navigationItem.title = "SalesOrderItem"
            masterViewController = salesOrderItemMasterViewController
        case .customers:
            let customerStoryBoard = UIStoryboard(name: "Customer", bundle: nil)
            let customerMasterViewController = customerStoryBoard.instantiateViewController(withIdentifier: "CustomerMaster") as! CustomerMasterViewController
            customerMasterViewController.espmContainer = espmContainer
            customerMasterViewController.entitySetName = "Customers"
            func fetchCustomers(_ completionHandler: @escaping ([Customer]?, Error?) -> Void) {
                // Only request the first 20 values. If you want to modify the requested entities, you can do it here.
                let query = DataQuery().selectAll().top(20)
                do {
                    espmContainer.fetchCustomers(matching: query, completionHandler: completionHandler)
                }
            }
            customerMasterViewController.loadEntitiesBlock = fetchCustomers
            customerMasterViewController.navigationItem.title = "Customer"
            masterViewController = customerMasterViewController
        case .salesOrderHeaders:
            let salesOrderHeaderStoryBoard = UIStoryboard(name: "SalesOrderHeader", bundle: nil)
            let salesOrderHeaderMasterViewController = salesOrderHeaderStoryBoard.instantiateViewController(withIdentifier: "SalesOrderHeaderMaster") as! SalesOrderHeaderMasterViewController
            salesOrderHeaderMasterViewController.espmContainer = espmContainer
            salesOrderHeaderMasterViewController.entitySetName = "SalesOrderHeaders"
            func fetchSalesOrderHeaders(_ completionHandler: @escaping ([SalesOrderHeader]?, Error?) -> Void) {
                // Only request the first 20 values. If you want to modify the requested entities, you can do it here.
                let query = DataQuery().selectAll().top(20)
                do {
                    espmContainer.fetchSalesOrderHeaders(matching: query, completionHandler: completionHandler)
                }
            }
            salesOrderHeaderMasterViewController.loadEntitiesBlock = fetchSalesOrderHeaders
            salesOrderHeaderMasterViewController.navigationItem.title = "SalesOrderHeader"
            masterViewController = salesOrderHeaderMasterViewController
        case .products:
            let productStoryBoard = UIStoryboard(name: "Product", bundle: nil)
            let productMasterViewController = productStoryBoard.instantiateViewController(withIdentifier: "ProductMaster") as! ProductMasterViewController
            productMasterViewController.espmContainer = espmContainer
            productMasterViewController.entitySetName = "Products"
            func fetchProducts(_ completionHandler: @escaping ([Product]?, Error?) -> Void) {
                // Only request the first 20 values. If you want to modify the requested entities, you can do it here.
                
                let query = DataQuery().selectAll().top(20)
                do {
                    espmContainer.fetchProducts(matching: query, completionHandler: completionHandler)
                }
            }
            productMasterViewController.loadEntitiesBlock = fetchProducts
            productMasterViewController.navigationItem.title = "Product"
            masterViewController = productMasterViewController
        case .none:
            masterViewController = UIViewController()
        }

        // Load the NavigationController and present with the EntityType specific ViewController
        let mainStoryBoard = UIStoryboard(name: "Main", bundle: nil)
        let rightNavigationController = mainStoryBoard.instantiateViewController(withIdentifier: "RightNavigationController") as! UINavigationController
        rightNavigationController.viewControllers = [masterViewController]
        self.splitViewController?.showDetailViewController(rightNavigationController, sender: nil)
    }

    // MARK: - Handle highlighting of selected cell

    private func makeSelection() {
        if let selectedIndex = selectedIndex {
            tableView.selectRow(at: selectedIndex, animated: true, scrollPosition: .none)
            tableView.scrollToRow(at: selectedIndex, at: .none, animated: true)
        } else {
            selectDefault()
        }
    }

    private func selectDefault() {
        // Automatically select first element if we have two panels (iPhone plus and iPad only)
        if self.splitViewController!.isCollapsed || OnboardingSessionManager.shared.onboardingSession?.odataController.espmContainer == nil {
            return
        }
        let indexPath = IndexPath(row: 0, section: 0)
        self.tableView.selectRow(at: indexPath, animated: true, scrollPosition: .middle)
        self.collectionSelected(at: indexPath)
    }
}

[/code]

Once you’ve added your handlers in Xcode, save and build the application.

Test a few of the voice commands:

  • Open products
  • What products do you have?
  • Show notebooks less than 1500 euros
  • What’s the price of the Notebook Basic 15?

And that concludes our integration and Visual Voice experience for this SAP sample application. This application was created as part of Alan and SAP’s partnership to voice enable the enterprise. Here’s a full video on the integration. For more details, please check out Alan’s documentation here.

Feel free to provide your feedback or just ask about support via sergey@alan.app

Leave a Reply

Discover more from Alan Blog

Subscribe now to keep reading and get access to the full archive.

Continue reading