Customize the Wizard Generated Application
- How to customize the values displayed in an object cell
- How to modify the navigation between screens
- How to change menu options
- How to add a Fiori search UI enabling the filtering of object cells on a list screen
- How to add a collection view showing the top products
Prerequisites
- You have Set Up a BTP Account for Tutorials. Follow the instructions to get an account, and then to set up entitlements and service instances for the following BTP services.
- SAP Mobile Services
- You completed Try Out the SAP BTP SDK Wizard for Android.
- Step 1
-
Run the previously created project.
-
Tap the Products entity.
Notice that it displays the category name rather than the product name.
The category name is displayed (rather than the product name) because the app was generated from the OData service’s metadata, which does not indicate which of the many fields from the product entity to display. When creating the sample user interface, the SDK wizard uses the first property found as the value to display. To view the complete metadata document, open the
res/raw/com_sap_edm_sampleservice_v2.xml
file.Each product is displayed in an object cell, which is one of the Fiori UI for Android controls.
As seen above, an object cell is used to display information about an entity.
What effect does the `masterPropertyName` have on the entity list screen?
-
- Step 2
In this section, you will configure the object cell to display a product’s name, category, description, and price.
-
In Android Studio, on Windows, press
Ctrl+N
, or, on a Mac, presscommand+O
, and typeProductsListFragment
, to openProductsListFragment.kt
. -
On Windows, press
Ctrl+F12
, or, on a Mac, presscommand+F12
, and typepopulateObjectCell
, to move to thepopulateObjectCell
method. Change the parameter in the first line of the method ingetOptionalValue
fromProduct.category
toProduct.name
. This will cause the product name to be shown as the headline value of the object cell:KotlinCopyval dataValue = productEntity.getOptionalValue(Product.name)
-
Replace the
viewHolder.objectCell.apply
block with the following code, which will display category, description, and price.KotlinCopyviewHolder.objectCell.apply { headline = masterPropertyValue setUseCutOut(false) (productEntity.getDataValue(Product.category))?.let { subheadline = it.toString() } (productEntity.getDataValue(Product.shortDescription))?.let { footnote = it.toString() } (productEntity.getDataValue(Product.price))?.let { statusWidth = 200 setStatus("$ $it", 1) } }
-
On Windows press
Ctrl+F12
, or, on a Mac, presscommand+F12
, and typeonViewStateRestored
to move to theonViewStateRestored
method. -
Replace
fragmentBinding.itemList?.let
block with the following code, which adds a divider between product items.KotlinCopyfragmentBinding.itemList.let { val linearLayoutManager = LinearLayoutManager(currentActivity) val dividerItemDecoration = DividerItemDecoration(it.context, linearLayoutManager.orientation) it.addItemDecoration(dividerItemDecoration) it.layoutManager = linearLayoutManager this.adapter = ProductListAdapter(currentActivity, it) it.adapter = this.adapter }
If classes
LinearLayoutManager
andDividerItemDecoration
appear red, this indicates that Android Studio could not locate the classes. Select each class and on Windows pressAlt+Enter
, or, on a Mac, pressoption+return
to make use of Android Studio quick fix to add the missing imports.An alternate option is to enable the below setting. (Windows: Settings, Mac: Android Studio > Settings…)
-
On Windows, press
Ctrl+N
, or, on a Mac, presscommand+O
, and typeRepository
to openRepository.kt
. -
On Windows, press
Ctrl+F12
, or, on a Mac, presscommand+F12
, and typeread
to move to theread()
method. -
Replace
if (orderByProperty != null)
block with the following code to specify that the sort order be by category and then by name for products.KotlinCopyorderByProperty?.let { dataQuery = dataQuery.orderBy(it, SortOrder.ASCENDING) if (entitySet.entityType == ESPMContainerMetadata.EntityTypes.product) { dataQuery.thenBy(Product.name, SortOrder.ASCENDING) } }
-
Re-run (quit first) the app and notice that the Products screen has been formatted to show the product’s name, category, description, and price and the entries are now sorted by category and then name.
Which properties of the object cell were set in this section?
-
- Step 3
Examine the
ProductCategories
screen.In this section, you will update the screen’s title, configure the object cell to show the category name, main category name, add the number of products in a category, and add a separator decoration between cells.
-
Press
Shift
twice and typestrings.xml
to openres/values/strings.xml
. -
Add the following entry:
XMLCopy<string name="product_categories_title">Product Categories</string>
-
On Windows, press
Ctrl+N
, or, on a Mac, presscommand+O
, and typeProductCategoriesListFragment
, to openProductCategoriesListFragment.kt
. -
On Windows, press
Ctrl+F12
, or, on a Mac, presscommand+F12
, and typeonViewStateRestored
to move to theonViewStateRestored
method, find thecurrentActivity.title = activityTitle
line. -
On Windows, press
Ctrl+/
, or, on a Mac, presscommand+/
, to comment out the line. -
Add the following line right after the line to set the screen’s title:
KotlinCopycurrentActivity.title = resources.getString(R.string.product_categories_title)
-
Still in this method, replace the
fragmentBinding.itemList?.let
block with the following code, which adds a divider between categories:KotlinCopyfragmentBinding.itemList.let { val linearLayoutManager = LinearLayoutManager(currentActivity) val dividerItemDecoration = DividerItemDecoration(it.context, linearLayoutManager.orientation) it.addItemDecoration(dividerItemDecoration) it.layoutManager = linearLayoutManager this.adapter = ProductCategoryListAdapter(currentActivity, it) it.adapter = this.adapter }
-
On Windows, press
Ctrl+F12
, or, on a Mac, presscommand+F12
, and typepopulateObjectCell
, to move to thepopulateObjectCell
method. -
Replace the
viewHolder.objectCell.apply
block with the following to display the main category instead, hide the footnote, and show the number of products per category.KotlinCopyviewHolder.objectCell.apply { headline = masterPropertyValue detailImage = null setDetailImage(viewHolder, productCategoryEntity) (productCategoryEntity.getDataValue(ProductCategory.mainCategoryName))?.let { subheadline = it.toString() } lines = 2 //Not using footnote (productCategoryEntity.getDataValue(ProductCategory.numberOfProducts))?.let { statusWidth = 220 setStatus("$it Products", 1) } }
-
Run the app again and notice that the title, subheadline, and status are now displayed and the icon and footnote are no longer shown.
-
- Step 4
In this section, you will modify the app to initially show the Product Categories screen when opened. Selecting a category will navigate to a Products screen for the selected category. The floating action button on the Categories screen will be removed.
-
On Windows, press
Ctrl+N
, or, on a Mac, presscommand+O
, and typeMainBusinessActivity
, to openMainBusinessActivity.kt
. -
On Windows, press
Ctrl+F12
, or, on a Mac, presscommand+F12
, and typestartEntitySetListActivity
, to move to thestartEntitySetListActivity
method. -
Add the following line below the other Intent declaration:
KotlinCopyval pcIntent = Intent(this, ProductCategoriesActivity::class.java)
-
After the call to
startActivity(intent)
, add the following line:KotlinCopystartActivity(pcIntent)
This will cause the Product Categories screen to be the first screen seen when opening the app, but because the EntityList screen is opened first, it can be navigated to by pressing the Back button. The EntityList screen contains the Settings menu so, to simplify things, this screen is still displayed.
-
On Windows, press
Ctrl+N
, or, on a Mac, presscommand+O
, and typeProductCategoriesListFragment
, to openProductCategoriesListFragment.kt
. -
On Windows, press
Ctrl+F12
, or, on a Mac, presscommand+F12
, and typeonViewStateRestored
to move to theonViewStateRestored
method. -
Replace the
fragmentBinding.fab?.let
block with the following code:KotlinCopyfragmentBinding.fab.hide()
-
Add the
onCreateMenu
method into the class right after theonCreateView
method.
Kotlin override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { super.onCreateMenu(menu, menuInflater) menu.removeItem(R.id.menu_home) }
-
On Windows, press
Ctrl+F12
, or, on a Mac, presscommand+F12
, and typesetOnClickListener
, to move to thesetOnClickListener
method. -
Replace the code with the following, which will enable the navigation from the Category list screen to the Product list screen.
KotlinCopyholder.itemView.setOnClickListener { view -> val productsIntent = Intent(currentActivity, ProductsActivity::class.java) productsIntent.putExtra("category", productCategoryEntity.categoryName) view.context.startActivity(productsIntent) }
-
On Windows, press
Ctrl+N
, or, on a Mac, presscommand+O
, and typeProductsListFragment
, to openProductsListFragment.kt
. -
On Windows, press
Ctrl+F
, or, on a Mac, presscommand+F
, and search forlistAdapter.setItems(entityList)
. Replace that line with the following code, which will filter the products list to only show products for a selected category.KotlinCopycurrentActivity.intent.getStringExtra("category")?.let { category -> val matchingProducts = arrayListOf<Product>() for (product in entityList) { product.category?.let { if (it == category) { matchingProducts.add(product) } } } listAdapter.setItems(matchingProducts) } ?: listAdapter.setItems(entityList)
-
Run the app again and notice that the Product Categories screen is now the first screen shown, that the Home menu is no longer shown, and that selecting a category shows the products list screen, which now displays only products for the selected category.
-
- Step 5
In this section you will add a search field to
ProductCategoriesListActivity
, enabling a user to filter the results displayed on the product category screen.-
First, right-click the
res/drawable
folder to create a new Drawable Resource Fileic_search_icon.xml
, and use the following XML content.XMLCopy<?xml version="1.0" encoding="utf-8"?> <vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> <path android:fillColor="#000" android:pathData="M15.5,14h-0.79l-0.28,-0.27C15.41,12.59 16,11.11 16,9.5 16,5.91 13.09,3 9.5,3S3,5.91 3,9.5 5.91,16 9.5,16c1.61,0 3.09,-0.59 4.23,-1.57l0.27,0.28v0.79l5,4.99L20.49,19l-4.99,-5zM9.5,14C7.01,14 5,11.99 5,9.5S7.01,5 9.5,5 14,7.01 14,9.5 11.99,14 9.5,14z"/> </vector>
The current menu
res/menu/itemlist_menu.xml
is shared among all list screens. We will now use a new XML file for the Product Categories screen. -
Right-click the
res/menu
folder to add a new Menu Resource File namedproduct_categories_menu.xml
, and use the following XML for its contents.XMLCopy<?xml version="1.0" encoding="utf-8"?> <menu xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto"> <item android:id="@+id/action_search" android:icon="@drawable/ic_search_icon" android:title="Search" app:actionViewClass="com.sap.cloud.mobile.fiori.search.FioriSearchView" app:showAsAction="always|collapseActionView" style="@style/FioriSearchView" /> <item android:id="@+id/menu_refresh" android:icon="@drawable/ic_menu_refresh" app:showAsAction="always" android:title="@string/menu_refresh"/> </menu>
-
On Windows, press
Ctrl+N
, or, on a Mac, presscommand+O
, and typeProductCategoryListAdapter
, to open theProductCategoryListAdapter
class, which is in theProductCategoriesListFragment.kt
file. -
Add the following member and methods to the top of this class.
KotlinCopyvar allProductCategories = listOf<ProductCategory>() fun setProductCategories(productCategories: MutableList<ProductCategory>) { this.productCategories = productCategories }
-
On Windows, press
Ctrl+F12
, or, on a Mac, presscommand+F12
, and typesetItems
, to move to thesetItems
method. -
Add the following to the top of the function:
KotlinCopyif (allProductCategories.isEmpty()) { allProductCategories = currentProductCategories }
-
On Windows, press
Ctrl+F12
, or, on a Mac, presscommand+F12
, and typeonCreateMenu
, to move to theonCreateMenu
method. -
Replace the contents of the method with the following code, which uses the new
product_categories_menu
and sets a listener that will filter the list of categories in the list when text is entered in the search view. (Make sure to import all the un-imported classes withalt+Enter
on Windows oroption+Enter
on Macs.)KotlinCopymenuInflater.inflate(R.menu.product_categories_menu, menu) val searchView = menu.findItem(R.id.action_search).actionView as FioriSearchView searchView.setBackgroundResource(R.color.transparent) // make sure to import androidx.appcompat.widget.SearchView searchView.setOnQueryTextListener(object: SearchView.OnQueryTextListener { override fun onQueryTextSubmit(s: String): Boolean { return false } override fun onQueryTextChange(newText: String): Boolean { adapter?.let { adapter -> val filteredCategoriesList = mutableListOf<ProductCategory>() if (newText.trim().isNotEmpty()) { for (i in adapter.allProductCategories.indices) { val pc = adapter.allProductCategories[i] pc.categoryName?.let { if (it.lowercase().contains(newText.lowercase())) { filteredCategoriesList.add(pc) } } } } else { for (i in adapter.allProductCategories.indices) { filteredCategoriesList.add(adapter.allProductCategories[i]) } } adapter.setProductCategories(filteredCategoriesList) return false } ?: return false } })
-
Run the app again and notice that there is now a search toolbar item.
-
Try it out: click the search item, enter some text, press
Enter
, and notice that the product categories that are displayed in the list are now filtered.
Further information on the Fiori search UI can be found at SAP Fiori for Android Design Guidelines and Fiori Search User Interface.
-
- Step 6
In this section, you will add a Top Products section to the Products screen, which displays the products that have the most sales, as shown below.
First, we’ll generate additional sales data in the sample OData service.
-
In SAP Mobile Services cockpit, navigate to Mobile Applications > Native/Hybrid > com.sap.wizapp and go to Mobile Sample OData ESPM.
-
Change the Entity Sets dropdown to
SalesOrderItems
and then click the generate sample sales orders icon five times. This will create additional sales order items, which we can use to base our top products on, based on the quantity sold. -
In Android Studio, on Windows, press
Ctrl+Shift+N
, or, on a Mac, presscommand+Shift+O
, and typefragment_entityitem_list
, to openfragment_entityitem_list.xml
. -
Replace the
fragment_entityitem_list.xml
content with the following code. This adds theCollectionView
to the Products pane when created.XMLCopy<?xml version="1.0" encoding="utf-8"?> <FrameLayout xmlns:android="http://schemas.android.com/apk/res/android" xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" android:layout_height="match_parent"> <com.google.android.material.floatingactionbutton.FloatingActionButton android:id="@+id/fab" android:layout_width="wrap_content" android:layout_height="wrap_content" android:layout_gravity="bottom|end" android:layout_margin="@dimen/fab_margin" android:src="@drawable/ic_add_circle_outline_black_24dp" app:tint="@color/colorWhite" app:backgroundTint="?attr/sap_fiori_color_accent_7" app:fabSize="normal" /> <LinearLayout android:layout_width="match_parent" android:layout_height="match_parent" android:orientation="vertical" android:id="@+id/wrapperLayout" > <com.sap.cloud.mobile.fiori.object.CollectionView app:layout_scrollFlags="scroll|enterAlways" android:id="@+id/collectionView" android:layout_height="wrap_content" android:layout_width="match_parent" android:background="@color/transparent" tools:minHeight="200dp"> </com.sap.cloud.mobile.fiori.object.CollectionView> <androidx.swiperefreshlayout.widget.SwipeRefreshLayout android:id="@+id/swiperefresh" android:layout_width="match_parent" android:layout_height="match_parent"> <androidx.recyclerview.widget.RecyclerView android:id="@+id/item_list" android:name="ItemListFragment" android:layout_width="match_parent" android:layout_height="match_parent" app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" /> </androidx.swiperefreshlayout.widget.SwipeRefreshLayout> </LinearLayout> </FrameLayout>
-
On Windows, press
Ctrl+N
, or, on a Mac, presscommand+O
, and typeProductsListFragment
, to openProductsListFragment.kt
. -
Add the following import libraries to the top of the document:
KotlinCopyimport android.widget.LinearLayout import androidx.fragment.app.FragmentActivity import com.sap.cloud.android.odata.espmcontainer.SalesOrderItem import com.sap.cloud.mobile.fiori.common.FioriItemClickListener import com.sap.cloud.mobile.fiori.`object`.AbstractEntityCell import com.sap.cloud.mobile.fiori.`object`.CollectionView import com.sap.cloud.mobile.fiori.`object`.CollectionViewItem import com.sap.cloud.mobile.odata.DataQuery import java.util.* import kotlin.collections.ArrayList import kotlin.collections.HashMap import kotlin.collections.LinkedHashMap
-
Add the following variables to the top of the
ProductsListFragment
class:KotlinCopyprivate val productList = arrayListOf<Product>() private var salesList = HashMap<String, Int>() private val productTracker = HashMap<String, Product>()
-
On Windows, press
ctrl+F12
, or, on a Mac, presscommand+F12
, and typeresetSelected
, to move to theresetSelected
method. -
Change the modifier from
private
tointernal
-
Do the same to
resetPreviouslyClicked
method. -
Add the following method to the
companion object
section:KotlinCopy// Function to sort hashmap by values fun sortByValue(hashmap: HashMap<String, Int>): HashMap<String, Int> { // Create a list from elements of HashMap val list: List<Map.Entry<String, Int>> = LinkedList<Map.Entry<String, Int>>(hashmap.entries) // Sort the list Collections.sort(list) { o1, o2 -> o2.value.compareTo(o1.value) } // Put data from sorted list into the linked hashmap val temp: HashMap<String, Int> = LinkedHashMap<String, Int>() for ((key, value) in list) { temp[key] = value LOGGER.debug("CollectionView: id = $key, count = $value") } return temp }
-
Add the following methods to the class:
KotlinCopy// Function to query the products private fun queryProducts() { val sapServiceManager = (currentActivity.application as SAPWizardApplication).sapServiceManager val query = DataQuery().orderBy(Product.productID) LOGGER.debug("CollectionView $query") val espmContainer = sapServiceManager?.eSPMContainer espmContainer?.let { it.getProductsAsync(query, {queryProducts: List<Product> -> LOGGER.debug("CollectionView: executed query in onCreate") for (product in queryProducts) { LOGGER.debug("CollectionView ${product.name} : ${product.productID} : ${product.price}") productTracker[product.productID] = product } LOGGER.debug("CollectionView: size of topProducts = ${queryProducts.size}") createTopProductsList() val cv: CollectionView = currentActivity.findViewById(R.id.collectionView) createCollectionView(cv) }, {re: RuntimeException -> LOGGER.debug("CollectionView: An error occurred during products async query: ${re.message}") }) } } // Function to order product list by the sorted sales list private fun createTopProductsList() { for ((key, value) in salesList) { productList.add(productTracker[key]!!) } } // Function to set features of the CollectionView private fun createCollectionView(cv: CollectionView) { LOGGER.debug("CollectionView: in createCollectionView method") cv.apply { setHeader(" Top Products") setFooter(" SEE ALL (${productTracker.size})") // If the footer "SEE ALL" is clicked then the Products page will open setFooterClickListener { visibility = View.GONE } // If any object is clicked in CollectionView then the Product's detail page for that object will open setItemClickListener(object: FioriItemClickListener { override fun onClick(view: View, position: Int) { LOGGER.debug("You clicked on: ${productList[position].name}(${productList[position].productID})") showProductDetailActivity(view.context, UIConstants.OP_READ, productList[position]) } override fun onLongClick(view: View, position: Int) { Toast.makeText(currentActivity.applicationContext, "You long clicked on: $position", Toast.LENGTH_SHORT).show() } }) val collectionViewAdapter = CollectionViewAdapter(currentActivity, productList.toList()) setCollectionViewAdapter(collectionViewAdapter) } if (resources.getBoolean(R.bool.two_pane)) { refreshLayout = currentActivity.findViewById(R.id.swiperefresh) val linearLayout = currentActivity.findViewById<LinearLayout>(R.id.wrapperLayout) val height = linearLayout.height - cv.height refreshLayout.minimumHeight = height } } // Opens the product's detail page activity private fun showProductDetailActivity(context: Context, operation: String, productEntity: Product?) { productEntity?.let { LOGGER.debug("within showProductDetailActivity for ${it.name}") val isNavigationDisabled = (currentActivity as ProductsActivity).isNavigationDisabled if (isNavigationDisabled) { Toast.makeText(currentActivity, "Please save your changes first...", Toast.LENGTH_LONG).show() } else { adapter?.resetSelected() adapter?.resetPreviouslyClicked() viewModel.setSelectedEntity(it) listener?.onFragmentStateChange(UIConstants.EVENT_ITEM_CLICKED, it) } } } private class CollectionViewAdapter(activity: FragmentActivity, productList: List<Product>) : CollectionView.CollectionViewAdapter() { private val products: List<Product> private val currentActivity: FragmentActivity override fun onBindViewHolder(collectionViewItemHolder: CollectionViewItemHolder, i: Int) { val cvi: CollectionViewItem = collectionViewItemHolder.collectionViewItem val prod = products[i] val productName = prod.name cvi.apply { detailImage = null headline = productName subheadline = prod.categoryName + "" imageOutlineShape = AbstractEntityCell.IMAGE_SHAPE_OVAL prod.pictureUrl?.let { val sapServiceManager = (currentActivity.application as SAPWizardApplication).sapServiceManager prepareDetailImageView().scaleType = ImageView.ScaleType.FIT_CENTER sapServiceManager?.let {sapServiceManager -> Glide.with(currentActivity.applicationContext) .load(EntityMediaResource.getMediaResourceUrl(prod, sapServiceManager.serviceRoot)) // Import com.bumptech.glide.Glide for RequestOptions() .apply(RequestOptions().fitCenter()) .transition(DrawableTransitionOptions.withCrossFade()) .into(prepareDetailImageView()) } } ?: run { // No picture is available, so use a character from the product string as the image thumbnail detailImageCharacter = productName?.substring(0, 1) setDetailCharacterBackgroundTintList(com.sap.cloud.mobile.fiori.R.color.sap_ui_contact_placeholder_color_1) } } } override fun getItemCount(): Int { return products.size } init { products = productList currentActivity = activity } }
-
On Windows, press
Ctrl+F12
, or, on a Mac, presscommand+F12
, and typeprepareViewModel
, to move to theprepareViewModel
method. -
Replace the
ViewModelProvider(currentActivity).get(ProductViewModel::class.java)
line of the method with the following code:KotlinCopyViewModelProvider(currentActivity).get(ProductViewModel::class.java).also { it.initialRead{errorMessage -> showError(errorMessage) } val cv: CollectionView = currentActivity.findViewById(R.id.collectionView) createCollectionView(cv) }
-
On Windows, press
Ctrl+F12
, or, on a Mac, presscommand+F12
, and typeonCreate
, to move to theonCreate
method. -
Add the following lines of code at the end of the method:
KotlinCopy// Query the SalesOrderItems and order by gross amount received from sales // Change the orderBy arguments to SalesOrderItem.property_name to rearrange the CollectionView order of products val dq = DataQuery().orderBy(SalesOrderItem.productID) // Get the DataService class, which we will use to query the back-end OData service val espmContainer = sapServiceManager?.eSPMContainer espmContainer?.let { it.getSalesOrderItemsAsync(dq, { querySales: List<SalesOrderItem>? -> LOGGER.debug("CollectionView: executed sales order query in onCreate") querySales?.let { querysales -> for (sale in querysales) { if (salesList.containsKey(sale.productID)) { salesList[sale.productID] = salesList[sale.productID]!!.toInt() + sale.quantity!!.intValueExact() } else { salesList[sale.productID] = sale.quantity!!.intValueExact() } LOGGER.debug("CollectionView ${sale.productID} : ${sale.quantity} : ${sale.grossAmount}") } salesList = sortByValue(salesList) LOGGER.debug("CollectionView: salesList size = ${salesList.size}") queryProducts() } ?: LOGGER.debug("CollectionView: sales query list is null") }, { re: RuntimeException -> LOGGER.debug("CollectionView: An error occurred during async sales query: ${re.message}")}) }
-
Run the app and notice that the Products screen now has a component at the top of the screen that allows horizontal scrolling to view the top products. Tap a product to see more details. Alternatively, tap SEE ALL to see all the products.
For more details, see Collection View in SAP Fiori for Android Design Guidelines and Collection View
For more information on SAP Fiori for Android and the generated app, see Fiori UI Overview, SAP Fiori for Android Design Guidelines, Fiori UI Demo Application and the
WizardAppReadme.md
file located in the generated app.
Congratulations! You have now made use of SAP Fiori for Android and have an understanding of some of the ways that the wizard-generated application can be customized to show different fields on the list screens, add or remove menu items, perform a search, and use a collection view.
-