Add Visual Voice Experiences to your SAP Mobile Applications
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:
// // 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&amp;amp;amp;amp;lt;T: Any&amp;amp;amp;amp;gt; { private let policy: objc_AssociationPolicy public init(policy: objc_AssociationPolicy = .OBJC_ASSOCIATION_RETAIN_NONATOMIC) { self.policy = policy } public subscript(index: AnyObject) -&amp;amp;amp;amp;gt; 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&amp;amp;amp;amp;lt;AlanButton&amp;amp;amp;amp;gt;() private static let associationAlanText = ObjectAssociation&amp;amp;amp;amp;lt;AlanText&amp;amp;amp;amp;gt;() 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 } } } } }
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:
// // 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: -&amp;amp;amp;amp;nbsp;Properties let window: UIWindow /// Save ViewController while splash/onboarding screens are presented private var _savedApplicationRootViewController: UIViewController? private var _onboardingSplashViewController: (UIViewController &amp;amp;amp;amp;amp; InfoTextSettable)? private var _coveringViewController: UIViewController? // MARK: - Init public init(window: UIWindow) { self.window = window self.window.addAlan() } // MARK: - ApplicationUIManaging func hideApplicationScreen(completionHandler: @escaping (Error?) -&amp;amp;amp;amp;gt; 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?) -&amp;amp;amp;amp;gt; Void) { // splash already presented guard self._onboardingSplashViewController == nil else { completionHandler(nil) return } setupSplashScreen() completionHandler(nil) } func showSplashScreenForUnlock(completionHandler: @escaping (Error?) -&amp;amp;amp;amp;gt; Void) { guard self._onboardingSplashViewController == nil else { completionHandler(nil) return } self.saveApplicationScreenIfNecessary() setupSplashScreen() completionHandler(nil) } func showApplicationScreen(completionHandler: @escaping (Error?) -&amp;amp;amp;amp;gt; 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: -&amp;amp;amp;amp;nbsp;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 } }
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:
// // 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&amp;amp;amp;amp;amp;amp;amp;lt;T: Any&amp;amp;amp;amp;amp;amp;amp;gt; { private let policy: objc_AssociationPolicy public init(policy: objc_AssociationPolicy = .OBJC_ASSOCIATION_RETAIN_NONATOMIC) { self.policy = policy } public subscript(index: AnyObject) -&amp;amp;amp;amp;amp;amp;amp;gt; 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&amp;amp;amp;amp;amp;amp;amp;lt;NavigateViewDelegate&amp;amp;amp;amp;amp;amp;amp;gt;() private static let productDelegate = ObjectAssociation&amp;amp;amp;amp;amp;amp;amp;lt;ProductViewDelegate&amp;amp;amp;amp;amp;amp;amp;gt;() private static let associationAlanButton = ObjectAssociation&amp;amp;amp;amp;amp;amp;amp;lt;AlanButton&amp;amp;amp;amp;amp;amp;amp;gt;() private static let associationAlanText = ObjectAssociation&amp;amp;amp;amp;amp;amp;amp;lt;AlanText&amp;amp;amp;amp;amp;amp;amp;gt;() 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?) -&amp;amp;amp;amp;amp;amp;amp;gt; 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() } } } }// // 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&amp;amp;amp;amp;amp;lt;OnlineODataProvider&amp;amp;amp;amp;amp;gt;! public var loadEntitiesBlock: ((_ completionHandler: @escaping ([Product]?, Error?) -&amp;amp;amp;amp;amp;gt; Void) -&amp;amp;amp;amp;amp;gt; 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) -&amp;amp;amp;amp;amp;gt; Int { return self.entities.count } override func tableView(_: UITableView, canEditRowAt _: IndexPath) -&amp;amp;amp;amp;amp;gt; Bool { return true } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -&amp;amp;amp;amp;amp;gt; 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?) -&amp;amp;amp;amp;amp;gt; 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) -&amp;amp;amp;amp;amp;gt; 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 () -&amp;amp;amp;amp;amp;gt; 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() } }// // 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 &amp;amp;amp;amp;lt; 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) -&amp;amp;amp;amp;gt; Int { return 1 } override func tableView(_: UITableView, numberOfRowsInSection _: Int) -&amp;amp;amp;amp;gt; Int { return collections.count } override func tableView(_: UITableView, heightForRowAt _: IndexPath) -&amp;amp;amp;amp;gt; CGFloat { return 44 } override func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -&amp;amp;amp;amp;gt; 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?) -&amp;amp;amp;amp;gt; 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?) -&amp;amp;amp;amp;gt; 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?) -&amp;amp;amp;amp;gt; 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?) -&amp;amp;amp;amp;gt; 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?) -&amp;amp;amp;amp;gt; 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?) -&amp;amp;amp;amp;gt; 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?) -&amp;amp;amp;amp;gt; 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?) -&amp;amp;amp;amp;gt; 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?) -&amp;amp;amp;amp;gt; 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?) -&amp;amp;amp;amp;gt; 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) } }
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
Originally published at http://blogs.alan.app on May 17, 2019.