19.
Finishing Touches
Written by Kevin D Moore
In this chapter, you’ll add some finishing touches that improve both the look and usability of PlaceBook. Even though PlaceBook is perfectly functional as-is, it’s often the subtle enhancements that make an app go from good to great. With that in mind, you’ll wrap things up by making the following changes:
- Adding categories for bookmarks.
- Displaying category-specific icons on the map.
- Adding place search.
- Adding ad-hoc bookmark creation.
- Adding bookmark deletions.
- Adding bookmark sharing.
- Updating the color scheme.
- Displaying progress using indicators.
Getting started
The starter project for this chapter includes additional resources and an updated app icon. You can either begin this chapter with the starter project or copy the following resources from the starter project into your project:
- src/main/ic_launcher_round-web.png
- src/main/ic_launcher-web.png
- src/main/res/drawable/ic_gas.png
- src/main/res/drawable/ic_lodging.png
- src/main/res/drawable/ic_restaurant.png
- src/main/res/drawable/ic_search_white.png
- src/main/res/drawable/ic_shopping.png
- src/main/res/mipmap/ic_launcher_round.png
- src/main/res/mipmap/ic_launcher.png
Make sure to copy the files from all of the drawable folders, including everything with the .hdpi, .mdpi, .xhdpi, and .xxhdpi extensions.
If you’re using the starter project, remember to replace the key in google_maps_api.xml.
Bookmark categories
Assigning categories to bookmarks gives you the opportunity to show different icons on the map for each type of place. Google already provides category information for Places, so you’ll use this to set a default category, and let the user assign a different category if they choose.
Updating the model
Start by adding a new category property to Bookmark.
Open Bookmark.kt and update the Bookmark declaration to add a category property:
var category: String = ""
so that the constructor looks like:
data class Bookmark(
@PrimaryKey(autoGenerate = true) var id: Long? = null,
var placeId: String? = null,
var name: String = "",
var address: String = "",
var latitude: Double = 0.0,
var longitude: Double = 0.0,
var phone: String = "",
var notes: String = "",
var category: String = ""
)
Open PlaceBookDatabase.kt and update the @Database annotation version to 3:
@Database(entities = arrayOf(Bookmark::class), version = 3)
Note: As mentioned in Chapter 17, if you don’t update the version number after modifying the model, Room will throw an exception. When you change the version number, Room creates a new database on the first run using the new version number.
Converting place types
If you examine Place defined by the Google Play Services, you’ll notice that it provides a fairly long list of place types:
int TYPE_OTHER = 0;
int TYPE_ACCOUNTING = 1;
int TYPE_AIRPORT = 2;
int TYPE_AMUSEMENT_PARK = 3;
int TYPE_AQUARIUM = 4;
int TYPE_ART_GALLERY = 5;
...
To keep things manageable, PlaceBook will support only four categories: Gas, Lodging, Restaurant, and Shopping. All other types will get assigned to the Other category.
To get started, you need a method that maps a Google Place type to a supported PlaceBook category.
Open BookmarkRepo.kt and add the following method:
private fun buildCategoryMap() : HashMap<Place.Type, String> {
return hashMapOf(
Place.Type.BAKERY to "Restaurant",
Place.Type.BAR to "Restaurant",
Place.Type.CAFE to "Restaurant",
Place.Type.FOOD to "Restaurant",
Place.Type.RESTAURANT to "Restaurant",
Place.Type.MEAL_DELIVERY to "Restaurant",
Place.Type.MEAL_TAKEAWAY to "Restaurant",
Place.Type.GAS_STATION to "Gas",
Place.Type.CLOTHING_STORE to "Shopping",
Place.Type.DEPARTMENT_STORE to "Shopping",
Place.Type.FURNITURE_STORE to "Shopping",
Place.Type.GROCERY_OR_SUPERMARKET to "Shopping",
Place.Type.HARDWARE_STORE to "Shopping",
Place.Type.HOME_GOODS_STORE to "Shopping",
Place.Type.JEWELRY_STORE to "Shopping",
Place.Type.SHOE_STORE to "Shopping",
Place.Type.SHOPPING_MALL to "Shopping",
Place.Type.STORE to "Shopping",
Place.Type.LODGING to "Lodging",
Place.Type.ROOM to "Lodging"
)
}
This builds a HashMap that relates Place types to category names. Any type not included in the list will end up mapping to the Other category, as you’ll see in placeTypeToCategory().
Add the following property to BookmarkRepo after the bookmarkDao definition:
private var categoryMap: HashMap<Place.Type, String> = buildCategoryMap()
You initialize categoryMap to hold the mapping of place types to category names.
Add the following method:
fun placeTypeToCategory(placeType: Place.Type): String {
var category = "Other"
if (categoryMap.containsKey(placeType)) {
category = categoryMap[placeType].toString()
}
return category
}
This method takes in a Place type and converts it to a valid category. category is initialized to "Other" by default. If categoryMap contains a key matching placeType, it’s assigned to category.
You may be wondering why toString() is used on the value retrieved from the categoryMap HashMap. The reason is that accessing a HashMap with a missing key will return a null value.
To satisfy the compiler, you must force a string value. In this case, you use containsKey() to ensure that the key is in the HashMap, so you’re safe.
It’s time to make use of the new icons provided in the starter project. The icons correspond to the categories, like so:
- ic_other = Other
- ic_gas = Gas
- ic_lodging = Lodging
- ic_restaurant = Restaurant
- ic_shopping = Shopping
First, you need to map the category names to the drawable resource files.
Add the following method to BookmarkRepo:
private fun buildCategories() : HashMap<String, Int> {
return hashMapOf(
"Gas" to R.drawable.ic_gas,
"Lodging" to R.drawable.ic_lodging,
"Other" to R.drawable.ic_other,
"Restaurant" to R.drawable.ic_restaurant,
"Shopping" to R.drawable.ic_shopping
)
}
This builds a HashMap that relates the category names to the category icon resource IDs.
Add the following property to BookmarkRepo after the categoryMap definition:
private var allCategories: HashMap<String, Int> = buildCategories()
You initialize allCategories to hold the mapping of category names to resource IDs.
Add the following method:
fun getCategoryResourceId(placeCategory: String): Int? {
return allCategories[placeCategory]
}
This method provides a public method to convert a category name to a resource ID.
Updating the view model
You’re ready to update the map’s view model to support bookmark categories.
Open MapsViewModel.kt and add the following private method:
private fun getPlaceCategory(place: Place): String {
// 1
var category = "Other"
val types = place.types
types?.let { placeTypes ->
// 2
if (placeTypes.size > 0) {
// 3
val placeType = placeTypes[0]
category = bookmarkRepo.placeTypeToCategory(placeType)
}
}
// 4
return category
}
This method converts a place type to a bookmark category.
The task is slightly complicated due to the possibility of multiple types getting assigned to a single place.
- The
categorydefaults to"Other"in case there’s no type assigned to the place. - The method first checks the
placeTypesList to see if it’s populated. - If so, you extract the first type from the List and call
placeTypeToCategory()to make the conversion. - Finally, you return the category.
Update addBookmarkFromPlace() and add the following assignment before the call to addBookmark():
bookmark.category = getPlaceCategory(place)
This assigns the category to the newly created bookmark.
Update the BookmarkView data class declaration to include a new category resource ID property:
data class BookmarkView(val id: Long? = null,
val location: LatLng = LatLng(0.0, 0.0),
val name: String = "",
val phone: String = "",
val categoryResourceId: Int? = null) {
This adds categoryResourceId and will hold the resource icon for the bookmark’s category.
Update bookmarkToBookmarkView() to reflect the new BookmarkView declaration:
private fun bookmarkToBookmarkView(bookmark: Bookmark): BookmarkView {
return BookmarkView(
bookmark.id,
LatLng(bookmark.latitude, bookmark.longitude),
bookmark.name,
bookmark.phone,
bookmarkRepo.getCategoryResourceId(bookmark.category))
}
Displaying categories on the map
You can now update the user interface to show the category icons.
The first change required is to ensure that the Places client returns the list of types associated with a place.
Open MapsActivity.kt, and in displayPoiGetPlaceStep() add Place.Field.TYPES to the list of placeFields.
The new assignment should look like this:
val placeFields = listOf(Place.Field.ID,
Place.Field.NAME,
Place.Field.PHONE_NUMBER,
Place.Field.PHOTO_METADATAS,
Place.Field.ADDRESS,
Place.Field.LAT_LNG,
Place.Field.TYPES)
Replace the call to map.addMarker() in addPlaceMarker() with the following:
val marker = map.addMarker(MarkerOptions()
.position(bookmark.location)
.title(bookmark.name)
.snippet(bookmark.phone)
.icon(bookmark.categoryResourceId?.let {
BitmapDescriptorFactory.fromResource(it)
})
.alpha(0.8f))
The change here is that you’re setting the icon to a bitmap which you load from the categoryResourceId property on the bookmark.
Build and run the app, and add bookmarks for a variety of place types. Notice the different icons that are displayed on the map.
Next, you need to update the navigation drawer to display the new category icons.
Open BookmarkListAdapter.kt. In onBindViewHolder(), replace the call to setImageResource() with the following:
bookmarkViewData.categoryResourceId?.let {
holder.binding.bookmarkIcon.setImageResource(it)
}
This first checks to see if the categoryResourceId is set and if so, it sets the image resource to the categoryResourceId.
Build and run the app. Open the navigation drawer and marvel at the beautiful category icons beside each bookmark.
Updating the details screen
There’s one last feature to add before moving on: you need to allow the user to change the category assigned to a place.
You’ll start by adding a new spinner UI widget to the bookmark details activity, allowing the user to select from the available categories.
First open up strings.xml and add:
<string name="category">Category</string>
This will add a string for our category label.
Open activity_bookmark_details.xml and add the following after the textViewName AppCompTextView:
<androidx.appcompat.widget.AppCompatTextView
android:id="@+id/textViewCategoryLabel"
style="@style/BookmarkLabel"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/category"
android:layout_marginStart="8dp"
app:layout_constraintEnd_toStartOf="@+id/barrier1"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintBottom_toBottomOf="@+id/spinnerCategory"
app:layout_constraintTop_toTopOf="@+id/spinnerCategory"
/>
This defines the label for Category. Now change textViewNotes to be below textViewCategoryLabel. From:
app:layout_constraintTop_toBottomOf="@+id/textViewName" />
To:
app:layout_constraintTop_toBottomOf="@+id/textViewCategoryLabel" />
Below the editTextName TextInputEditText field add:
<ImageView
android:id="@+id/imageViewCategory"
android:layout_width="24dp"
android:layout_height="24dp"
android:src="@drawable/ic_other"
android:layout_marginStart="16dp"
app:layout_constraintBottom_toBottomOf="@+id/spinnerCategory"
app:layout_constraintStart_toEndOf="@+id/barrier1"
app:layout_constraintTop_toTopOf="@+id/spinnerCategory"
/>
<Spinner
android:id="@+id/spinnerCategory"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight='1.4'
android:layout_marginTop="16dp"
android:layout_marginStart="8dp"
android:layout_marginEnd="8dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageViewCategory"
app:layout_constraintTop_toBottomOf="@+id/editTextName"
/>
Add the spinnerCategory to the barrier1 view:
app:constraint_referenced_ids="editTextName, editTextNotes,editTextPhone, editTextAddress,spinnerCategory" />
This displays the currently selected category icon using an ImageView. It also allows the user to select a new category using a Spinner.
Now change editTextNotes to be below spinnerCategory. From:
app:layout_constraintTop_toBottomOf="@+id/editTextName" />
To:
app:layout_constraintTop_toBottomOf="@+id/spinnerCategory" />
Before you can set the image and populate the spinner, you need to add support for bookmark categories in the view model for the detail View.
Open BookmarkDetailsViewModel.kt and update the BookmarkDetailsView declaration to include the category var category: String = "" property:
data class BookmarkDetailsView(var id: Long? = null,
var name: String = "",
var phone: String = "",
var address: String = "",
var notes: String = "",
var category: String = "") {
Update the return call in bookmarkToBookmarkView() to include the category:
return BookmarkDetailsView(
bookmark.id,
bookmark.name,
bookmark.phone,
bookmark.address,
bookmark.notes,
bookmark.category
)
Update bookmarkViewToBookmark() to include the category assignment after the bookmark.notes assignment line:
bookmark.category = bookmarkDetailsView.category
Add a new method to return a category resource ID from a category name:
fun getCategoryResourceId(category: String): Int? {
return bookmarkRepo.getCategoryResourceId(category)
}
This is a simple pass-through to a similar method in the bookmark repo.
To fill the spinner with options, you also need a method to return a list of all possible category names.
Open BookmarkRepo.kt and add the following property:
val categories: List<String>
get() = ArrayList(allCategories.keys)
This defines a get() accessor on categories that takes all of the HashMap keys, which are the category names, and returns them as an ArrayList of strings.
Open BookmarkDetailsViewModel.kt and add the following method:
fun getCategories(): List<String> {
return bookmarkRepo.categories
}
This is another simple pass-through method that returns the categories list from the bookmark repo.
Open BookmarkDetailsActivity.kt and add the following new method:
private fun populateCategoryList() {
// 1
val bookmarkView = bookmarkDetailsView ?: return
// 2
val resourceId = bookmarkDetailsViewModel.getCategoryResourceId(bookmarkView.category)
// 3
resourceId?.let { databinding.imageViewCategory.setImageResource(it) }
// 4
val categories = bookmarkDetailsViewModel.getCategories()
// 5
val adapter = ArrayAdapter(this, android.R.layout.simple_spinner_item, categories)
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
// 6
databinding.spinnerCategory.adapter = adapter
// 7
val placeCategory = bookmarkView.category
databinding.spinnerCategory.setSelection(adapter.getPosition(placeCategory))
}
Here’s how it works:
- The method returns immediately if
bookmarkDetailsViewisnull. - You retrieve the category icon
resourceIdfrom the view model. - If the
resourceIdis notnull, you updateimageViewCategoryto the category icon. - You retrieve the list of categories from the view model.
- This is the standard way to populate a
Spinnercontrol in Android. You first create an Adapter, in this case, a simpleArrayAdapterbuilt from the list of category names. Then, usingsetDropDownViewResource(), you assign the Adapter to a standard built-in Layout resource. - You then assign the Adapter to the
spinnerCategorycontrol. - You update
spinnerCategoryto reflect the current category selection.
Add a call to populateCategoryList() in getIntentData() after the populateImageView() call:
populateCategoryList()
Build and run the app. Open the details for a bookmark, and you’ll notice the spinner displays the assigned category and the appropriate icon is displayed to the left.
If you change the category and save the bookmark, you’ll discover two issues: the category icon does not update when the value is changed, and the category change is not saved. Time to fix that!
Add the following to the end of populateCategoryList():
// 1
databinding.spinnerCategory.post {
// 2
databinding.spinnerCategory.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>, view: View, position: Int, id: Long) {
// 3
val category = parent.getItemAtPosition(position) as String
val resourceId = bookmarkDetailsViewModel.getCategoryResourceId(category)
resourceId?.let {
databinding.imageViewCategory.setImageResource(it) }
}
override fun onNothingSelected(parent: AdapterView<*>) {
// NOTE: This method is required but not used.
}
}
}
This new block of code sets up a listener to respond when the user changes the category selection.
-
The need to use
spinnerCategory.postis due to an unfortunate side effect in Android whereonItemSelected()is always called once with an initial position of 0. This causes the spinner to reset back to the first category regardless of the selection you set programmatically.Using
postcauses the code block to be placed on the main thread queue, and the execution of the code inside the braces gets delayed until the next message loop. This eliminates the initial call by Android toonItemSelected(). -
You assign the
spinnerCategoryonItemSelectedListenerproperty to an instance of theonItemSelectedListenerclass that implementsonItemsSelected()andonNothingSelected(). -
When the user selects a new category, you call
onItemSelected(). You determine the newcategoryby the current spinner selection position, and updateimageViewCategoryto reflect the new category.
Update saveChanges() to add the following line after the assignment of bookmarkView.phone:
bookmarkView.category = databinding.spinnerCategory.selectedItem as String
This grabs the currently selected category and assigns it to the bookmarkView category.
Build and run the app. This time the category icon on the details screen updates as you change selections, and the new category persists when you save the changes.
Searching for places
What if the user is looking for a specific place and can’t find it on the map? No worries! The Google Places API provides a powerful search feature that you’ll take advantage of next. You’ll add a new search button overlay on the map to trigger the search feature.
The Google Places API provides an autocomplete search widget that you can easily display within your app. As the user types in a place name or address, the search widget displays a dynamic list of choices.
Note: If you want to customize the user experience entirely, you can also use the autocomplete feature programmatically. See the developer document here https://developers.google.com/places/android-api/autocomplete#get_place_predictions_programmatically for more details.
You can choose to either embed the autocomplete widget as a Fragment, or you can launch it as an Activity with an Intent. If you want a permanent search bar within your Activity, then the Fragment approach is more appropriate. In this case, a search button is provided, and the autocomplete widget shows as an Activity.
First, you need a method to kick off the search feature.
Adding PlaceAutocomplete search
Open MapsActivity.kt and add the following property to the companion object:
private const val AUTOCOMPLETE_REQUEST_CODE = 2
Then add the following method at the bottom of MapsActivity:
private fun searchAtCurrentLocation() {
// 1
val placeFields = listOf(
Place.Field.ID,
Place.Field.NAME,
Place.Field.PHONE_NUMBER,
Place.Field.PHOTO_METADATAS,
Place.Field.LAT_LNG,
Place.Field.ADDRESS,
Place.Field.TYPES)
// 2
val bounds = RectangularBounds.newInstance(map.projection.visibleRegion.latLngBounds)
try {
// 3
val intent = Autocomplete.IntentBuilder(
AutocompleteActivityMode.OVERLAY, placeFields)
.setLocationBias(bounds)
.build(this)
// 4
startActivityForResult(intent, AUTOCOMPLETE_REQUEST_CODE)
} catch (e: GooglePlayServicesRepairableException) {
Toast.makeText(this, "Problems Searching", Toast.LENGTH_LONG).show()
} catch (e: GooglePlayServicesNotAvailableException) {
Toast.makeText(this, "Problems Searching. Google Play Not available", Toast.LENGTH_LONG).show()
}
}
Here’s the code breakdown:
-
You define the fields, which informs the Autocomplete widget what attributes to return for each place.
-
You compute the bounds of the currently visible region of the map.
-
Autocompleteprovides anIntentBuildermethod to build up the Intent. You passAutocompleteActivityMode.OVERLAYto indicate that the search widget can overlay the current Activity. The other option isAutocompleteActivityMode.FULLSCREEN, which causes the search interface to replace the entire screen.You pass the map
boundstosetBoundBias(). This tells the search widget to look for places within the current map window before searching other areas. -
You start the Activity and pass a request code of
AUTOCOMPLETE_REQUEST_CODE. When the user finishes the search, the results are identified by this request code.
You surrounded the code with a try/catch block because IntentBuilder can throw exceptions if Google Play services are not working.
Add the following method as well:
override fun onActivityResult(
requestCode: Int,
resultCode: Int,
data: Intent?
) {
super.onActivityResult(requestCode, resultCode, data)
// 1
when (requestCode) {
AUTOCOMPLETE_REQUEST_CODE ->
// 2
if (resultCode == Activity.RESULT_OK && data != null) {
// 3
val place = Autocomplete.getPlaceFromIntent(data)
// 4
val location = Location("")
location.latitude = place.latLng?.latitude ?: 0.0
location.longitude = place.latLng?.longitude ?: 0.0
updateMapToLocation(location)
// 5
displayPoiGetPhotoStep(place)
}
}
}
onActivityResult() is called by Android when the user completes the search.
- First, you check the
requestCodeto make sure it matches theAUTOCOMPLETE_REQUEST_CODEpassed intostartActivityForResult(). - If the
resultCodeindicates the user found a place, and thedatais notnull, then you continue to process the results. - How do you get the actual place that was found by the user? Fortunately,
Autocompleteprovides a handy method,getPlaceFromIntent(), that takes the data and returns a populatedPlaceobject. - You convert the place
latLngto a location and pass that to the existingupdateMapToLocationmethod. This causes the map to zoom to the place. - Previously, when the user tapped on a place, several steps were created to process the data. In this case, you already have the place loaded, so you don’t need all of the steps, but you can start at the
displayPoiGetPhotoMetaDataStep()and pass it the found place. This loads the place photo and displays the place Info window.
Updating the UI
Next, you’ll surround the main map view with a frame Layout and add a floating search button on top of the map.
Open main_view_maps.xml and add the following before the top <LinearLayout> line:
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
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">
Remove the xmlns:app and xmlns:tools from the LinearLayout. Add the following after the closing </LinearLayout> line:
<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="16dp"
app:srcCompat="@drawable/ic_search_white"/>
</FrameLayout>
This tells the Layout engine to place the search button in the bottom-right corner of the map with a margin of 16dp on each side.
Now it’s just a matter of connecting the button to a variable and listening for a user tap.
Open MapsActivity.kt and add the following to the end of setupMapListeners():
databinding.mainMapView.fab.setOnClickListener {
searchAtCurrentLocation()
}
Build and run the app. Tap on the search icon and search for a place by name. Tap on one of the results and the map will zoom to the place and display the Info window.
Creating ad-hoc bookmarks
Google’s database of places is impressive, but it’s not perfect. What if the user wants to add a bookmark for a place that doesn’t show up on the map? You can make this possible by allowing the user to drop a pin at any location on the map.
Currently, MapsViewModel includes a method to create a bookmark from a place, but now you need one to create a bookmark from only a map location.
Open MapsViewModel.kt and add the following method:
fun addBookmark(latLng: LatLng) : Long? {
val bookmark = bookmarkRepo.createBookmark()
bookmark.name = "Untitled"
bookmark.longitude = latLng.longitude
bookmark.latitude = latLng.latitude
bookmark.category = "Other"
return bookmarkRepo.addBookmark(bookmark)
}
This takes in a LatLng location and creates a new untitled bookmark at the given location. Then, it returns the new bookmark ID to the caller.
Next, you need a method in MapsActivity.kt to take advantage of addBookmark. Open MapsActivity.kt and add the following method:
private fun newBookmark(latLng: LatLng) {
GlobalScope.launch {
val bookmarkId = mapsViewModel.addBookmark(latLng)
bookmarkId?.let {
startBookmarkDetails(it)
}
}
}
This method creates a new bookmark from a location, and then it starts the bookmark details Activity to allow editing of the new bookmark. The call to addBookmark runs within a coroutine block because it accesses the database and can’t run on the main thread.
You now need to listen for the user to long tap on the map.
Add the following to the end of setupMapListeners():
map.setOnMapLongClickListener { latLng ->
newBookmark(latLng)
}
Build and run the app.
Long-tap anywhere on the map and the bookmark Activity screen pops up with a new untitled bookmark using a default photo.
Name the bookmark, assign it a category and save the changes. The new bookmark appears at the location where you tapped on the map.
Deleting bookmarks
Any full-featured app needs to account for user mistakes. In PlaceBook, this means letting the user remove a bookmark that’s no longer needed or one that was added by accident. For this, you’ll add a trashcan action bar icon to the detail Activity to let the user delete a bookmark.
Open menu_bookmark_details.xml. Add the following before the action_save <item>:
<item
android:id="@+id/action_delete"
android:icon="@android:drawable/ic_menu_delete"
android:title="Delete"
app:showAsAction="ifRoom"/>
This adds a delete icon (trashcan) to the action bar menu to the left of the save icon.
Next, you’ll work your way up from the bottom-level code to the top, adding in basic support for deleting bookmarks.
In utils, create a new Kotlin file named FileUtils.kt and replace the contents with the following:
object FileUtils {
fun deleteFile(context: Context, filename: String) {
val dir = context.filesDir
val file = File(dir, filename)
file.delete()
}
}
This is a utility method that deletes a single file in the app’s main files directory. You’ll use this to delete the image associated with a deleted bookmark.
Open Bookmark.kt and add the following method:
fun deleteImage(context: Context) {
id?.let {
FileUtils.deleteFile(context, generateImageFilename(it))
}
}
This method uses FileUtils.deleteFile() to delete the image file associated with the current bookmark.
Note: Make sure you import your
FileUtilsclass and not Android’s.
Open BookmarkRepo.kt and add the following method:
fun deleteBookmark(bookmark: Bookmark) {
bookmark.deleteImage(context)
bookmarkDao.deleteBookmark(bookmark)
}
This method deletes the bookmark image and the bookmark from the database.
Open BookmarkDetailsViewModel.kt and add the following method:
fun deleteBookmark(bookmarkDetailsView: BookmarkDetailsView) {
GlobalScope.launch {
val bookmark = bookmarkDetailsView.id?.let {
bookmarkRepo.getBookmark(it)
}
bookmark?.let {
bookmarkRepo.deleteBookmark(it)
}
}
}
This method takes in a BookmarkDetailsView and loads the bookmark from the repo. If the bookmark is found, it calls deleteBookmark() on the repo. The code is wrapped in a coroutine, so it runs in the background.
Open BookmarkDetailsActivity.kt and add the following method:
private fun deleteBookmark()
{
val bookmarkView = bookmarkDetailsView ?: return
AlertDialog.Builder(this)
.setMessage("Delete?")
.setPositiveButton("Ok") { _, _ ->
bookmarkDetailsViewModel.deleteBookmark(bookmarkView)
finish()
}
.setNegativeButton("Cancel", null)
.create().show()
}
Note: Import the
androidxversion of AlertDialog
This method displays a standard AlertDialog to ask users if they want to delete the bookmark. If they select OK, it deletes the bookmark and the Activity closes using finish(). All of the support code is in place. Now you just need to respond to the delete menu action.
In onOptionsItemSelected(), add the following additional case to the when statement before the final else:
R.id.action_delete -> {
deleteBookmark()
return true
}
This calls deleteBookmark() when the delete icon is tapped. Since you’re deleting a bookmark that’s being observed with LiveData, some precautions are needed to prevent a crash.
Open BookmarkDetailsViewModel.kt and update mapBookmarkToView() as follows:
fun mapBookmarkToBookmarkView(bookmarkId: Long) {
val bookmark = bookmarkRepo.getLiveBookmark(bookmarkId)
bookmarkDetailsView = Transformations.map(bookmark) { repoBookmark ->
repoBookmark?.let { repoBookmark ->
bookmarkToBookmarkView(repoBookmark)
}
}
}
This ensures that a null bookmark is not passed to bookmarkToView by using the repoBookmark?.let statement.
Build and run the app. Edit an existing bookmark and use the delete icon to delete it. The bookmark is deleted, and you return to the map Activity.
Sharing bookmarks
Your users have painstakingly bookmarked some fantastic places, so why not let them share their good finds with friends?
Android allows you to share data with other apps using an Intent with an ACTION_SEND action. All you need to do is provide the data. Android figures out the apps that support your data type and presents the user with a list of choices.
Your next step is to build out an Intent that shares a URL providing directions to the bookmark place.
Open BookmarkDetailsViewModel.kt and update the BookmarkDetailsView data class declaration as follows:
data class BookmarkDetailsView(var id: Long? = null,
var name: String = "",
var phone: String = "",
var address: String = "",
var notes: String = "",
var category: String = "",
var longitude: Double = 0.0,
var latitude: Double = 0.0,
var placeId: String? = null) {
This adds longitude, latitude and placeId properties.
Update the return statement in bookmarkToBookmarkView() as follows:
return BookmarkDetailsView(
bookmark.id,
bookmark.name,
bookmark.phone,
bookmark.address,
bookmark.notes,
bookmark.category,
bookmark.longitude,
bookmark.latitude,
bookmark.placeId
)
The new longitude, latitude and placeId values are added to the BookmarkView call.
Open BookmarkDetailsActivity.kt and add the following method:
private fun sharePlace() {
// 1
val bookmarkView = bookmarkDetailsView ?: return
// 2
var mapUrl = ""
if (bookmarkView.placeId == null) {
// 3
val location = URLEncoder.encode("${bookmarkView.latitude},"
+ "${bookmarkView.longitude}", "utf-8")
mapUrl = "https://www.google.com/maps/dir/?api=1" +
"&destination=$location"
} else {
// 4
val name = URLEncoder.encode(bookmarkView.name, "utf-8")
mapUrl = "https://www.google.com/maps/dir/?api=1" +
"&destination=$name&destination_place_id=" +
"${bookmarkView.placeId}"
}
// 5
val sendIntent = Intent()
sendIntent.action = Intent.ACTION_SEND
// 6
sendIntent.putExtra(Intent.EXTRA_TEXT,
"Check out ${bookmarkView.name} at:\n$mapUrl")
sendIntent.putExtra(Intent.EXTRA_SUBJECT,
"Sharing ${bookmarkView.name}")
// 7
sendIntent.type = "text/plain"
// 8
startActivity(sendIntent)
}
Here’s what’s happening:
-
An early return is taken if
bookmarkViewisnull. -
This section of code builds out a Google Maps URL to trigger driving directions to the bookmarked place. Read the documentation at https://developers.google.com/maps/documentation/urls/guide for details about constructing map URLs.
There are two different styles of URLs to use depending on whether a place ID is available. If the user creates an ad-hoc bookmark, then the directions go directly to the latitude/longitude of the bookmark. If the bookmark is created from a place, then the directions go to the place based on its ID.
-
A string with the latitude/longitude separated by a comma is constructed. It’s encoded to allow the command to work in the URL. The final
mapUrlis constructed using thelocationstring. The final URL string looks like this: https://www.google.com/maps/dir/?api=1&destination=-84.56536026895046%2C35.+351035752390054 -
For the option with the place ID available, the destination contains the place name. The name string is URL encoded to make the input safe. The final
mapUrlis constructed using thenamestring and the place ID. The final URL string looks like this: https://www.google.com/maps/dir/?api=1&destination=Riverstone+Plaza&destination_place_id=ChIJAAAAAAAAAAAR1tSJBrRUoKI -
You create the sharing Activity Intent and set the action to
ACTION_SEND. This tells Android that this Intent is meant to share its data with another application installed on the device. -
Multiple types of extra data can be added to the Intent. The app that receives the Intent can choose which of the data items to use and which to ignore. For example, an email app will use the
ACTION_SUBJECT, but a messaging app will likely ignore it. There are several other extras available includingEXTRA_EMAIL,EXTRA_CC, andEXTRA_BCC. -
The Intent type is set to a MIME type of “text/plain”. This instructs Android that you intend to share plain text data. Any app in the system that registers an intent filter for the “text/plain” MIME type will be offered as a choice in the share dialog. If you were sharing binary data such as an image, you might use a MIME type of “image/jpeg”.
-
Finally, the sharing Activity is started.
Now, you need to add a floating share button to trigger the sharePlace method. Because you’ll use the same technique as you did when adding the search button on the map Activity, you’ll move through this with minimal explanation.
Open activity_bookmark_details.xml and add the following after the closing </NestedScrollView> line:
<com.google.android.material.floatingactionbutton.FloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_margin="16dp"
android:layout_gravity="bottom|end"
app:srcCompat="@android:drawable/ic_dialog_email"/>
Open BookmarkDetailsActivity.kt and add the following method:
private fun setupFab() {
databinding.fab.setOnClickListener { sharePlace() }
}
Add the following to the end of onCreate():
setupFab()
Build and run the app. Open a bookmark and tap the sharing button. You’ll see a share dialog similar to the following. Your choices may vary depending on the apps installed on your device.
Tap on Gmail, and it launches the Gmail app and populates the subject and message body.
Updating the color scheme
It’s a minor change, but updating the color scheme to match the bookmark icon colors will make the app look much better.
Open values/colors.xml and update the three colors:
<color name="colorPrimary">#3748AC</color>
<color name="colorPrimaryDark">#2A3784</color>
<color name="colorAccent">#E3A60B</color>
The primary color is a nice shade of blue and is used by the main action bar. The primary dark color is used by the status bar at the top and is a slightly darker version of the primary color. The accent color matches the yellow color of the bookmark icons. It’s used by the floating buttons and the highlight color when a field is in focus.
Build and run the app. The overall app colors look a lot better now.
Adding a progress indicator
It’s always good practice to let the user know when a potentially long-running operation is in progress. It also makes sense to prevent user interaction during this time. You’ll accomplish both of these tasks next.
Open main_view_maps.xml and add the following before the final </FrameLayout>:
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone"/>
This creates a hidden progress bar at the center of the Activity. In this case, “progress bar” is not the most appropriate term since what gets displayed is a circular progress indicator.
Open MapsActivity.kt and add the following new methods:
private fun disableUserInteraction() {
window.setFlags(WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
}
private fun enableUserInteraction() {
window.clearFlags(
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE)
}
disableUserInteraction() sets a flag on the main window to prevent user touches.
enableUserInteraction() clears the flag set by disableUserInteraction().
Add the following new methods:
private fun showProgress() {
databinding.mainMapView.progressBar.visibility = ProgressBar.VISIBLE
disableUserInteraction()
}
private fun hideProgress() {
databinding.mainMapView.progressBar.visibility = ProgressBar.GONE
enableUserInteraction()
}
showProgress() makes the progress bar visible and disables user interaction.
hideProgress() hides the progress bar and enables user interaction.
Now, you need to show and hide the progress bar in a few strategic locations.
You need to show progress when a place or place photo is loading. You must ensure that all calls to showProgress() are matched with a call to hideProgress() or the UI will remain frozen.
Add a call to showProgress() as the first line in displayPoi():
showProgress()
This displays the progress bar when a place is tapped.
Add a call to showProgress() in onActivityResult(), after the call to updateMapToLocation():
showProgress()
This displays the progress bar after searching for a place but before the place photo is loaded.
That’s it for showing the progress bar. Now you need to ensure that it goes away whether the place is successfully loaded or not.
Add a call to hideProgress() in displayPoiGetPlaceStep(), after the call to Log.e():
hideProgress()
This hides the progress bar if the place cannot be retrieved and the displayPoi steps end here.
In displayPoiGetPhotoStep(), add a call to hideProgress() as the last line in the addOnFailureListener code block:
hideProgress()
This hides the progress bar if there’s an error fetching the photo and the displayPoi steps end here.
In displayPoiDisplayStep() add a call to hideProgress() as the first line:
hideProgress()
This hides the progress bar before the new marker is shown.
Build and run the app. Tap on a new place to see the progress bar. Depending on the speed of your internet connection, it may flash almost too quickly to see, or it may spin for a couple of seconds.
Key Points
- Google’s Places API provides an extensive set of categories.
- You can use your own icons for categories.
- Adding subtle UI changes really helps the app look nice.
- Google provides a built-in search API and widget for locations.
- You can use Google’s autocomplete API programmatically.
- You can use Android’s sharing intent to share your locations.
Where to go from here?
Congratulations! You made it through the entire PlaceBook app section. You built a useful map-based app and learned a lot of new concepts along the way.
In the following section, you’ll take your Android skills to the next level and learn about networking, media playback, and more. Give yourself a well-deserved break, and then move on to the next section when you’re ready.