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