Chapters

Hide chapters

Android Apprentice

Fourth Edition · Android 11 · Kotlin 1.4 · Android Studio 4.1

Section II: Building a List App

Section 2: 7 chapters
Show chapters Hide chapters

Section III: Creating Map-Based Apps

Section 3: 7 chapters
Show chapters Hide chapters

19. Finishing Touches
Written by Kevin D Moore

Heads up... You're reading this book for free, with parts of this chapter shown beyond this point as scrambled text.

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.

var category: String = ""
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 = ""
)
@Database(entities = arrayOf(Bookmark::class), version = 3)

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;
...
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"
  )
}
private var categoryMap: HashMap<Place.Type, String> = buildCategoryMap()
fun placeTypeToCategory(placeType: Place.Type): String {
  var category = "Other"
  if (categoryMap.containsKey(placeType)) {
    category = categoryMap[placeType].toString()
  }
  return category
}
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
  )
}
private var allCategories: HashMap<String, Int> = buildCategories()
fun getCategoryResourceId(placeCategory: String): Int? {
    return allCategories[placeCategory]
}

Updating the view model

You’re ready to update the map’s view model to support bookmark categories.

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
}
bookmark.category = getPlaceCategory(place)
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) {
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.

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)
val marker = map.addMarker(MarkerOptions()
    .position(bookmark.location)
    .title(bookmark.name)
    .snippet(bookmark.phone)
    .icon(bookmark.categoryResourceId?.let {
        BitmapDescriptorFactory.fromResource(it)
     })
    .alpha(0.8f))

bookmarkViewData.categoryResourceId?.let {
    holder.binding.bookmarkIcon.setImageResource(it) 
}

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.

<string name="category">Category</string>
<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"
  />
app:layout_constraintTop_toBottomOf="@+id/textViewName" />
app:layout_constraintTop_toBottomOf="@+id/textViewCategoryLabel" />
<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"
  />
app:constraint_referenced_ids="editTextName, editTextNotes,editTextPhone, editTextAddress,spinnerCategory" />
app:layout_constraintTop_toBottomOf="@+id/editTextName" />
app:layout_constraintTop_toBottomOf="@+id/spinnerCategory" />
data class BookmarkDetailsView(var id: Long? = null,
                               var name: String = "",
                               var phone: String = "",
                               var address: String = "",
                               var notes: String = "",
                               var category: String = "") {
return BookmarkDetailsView(
    bookmark.id,
    bookmark.name,
    bookmark.phone,
    bookmark.address,
    bookmark.notes,
    bookmark.category
)
bookmark.category = bookmarkDetailsView.category
fun getCategoryResourceId(category: String): Int? {
  return bookmarkRepo.getCategoryResourceId(category)
}
val categories: List<String>
  get() = ArrayList(allCategories.keys)
fun getCategories(): List<String> {
  return bookmarkRepo.categories
}
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))
}
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.
    }
  }
}
bookmarkView.category = databinding.spinnerCategory.selectedItem as String

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.

Adding PlaceAutocomplete search

Open MapsActivity.kt and add the following property to the companion object:

private const val AUTOCOMPLETE_REQUEST_CODE = 2
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()
  }
}
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)
      }
  }
}

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.

<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">
  <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>
databinding.mainMapView.fab.setOnClickListener {
  searchAtCurrentLocation()
}

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.

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)
}
private fun newBookmark(latLng: LatLng) {
  GlobalScope.launch {
    val bookmarkId = mapsViewModel.addBookmark(latLng)
    bookmarkId?.let {
      startBookmarkDetails(it)
    }
  }
}
map.setOnMapLongClickListener { latLng ->
  newBookmark(latLng)
}

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.

<item
    android:id="@+id/action_delete"
    android:icon="@android:drawable/ic_menu_delete"
    android:title="Delete"
    app:showAsAction="ifRoom"/>
object FileUtils {
  fun deleteFile(context: Context, filename: String) {
    val dir = context.filesDir
    val file = File(dir, filename)
    file.delete()
  }
}
fun deleteImage(context: Context) {
  id?.let {
    FileUtils.deleteFile(context, generateImageFilename(it))
  }
}
fun deleteBookmark(bookmark: Bookmark) {
  bookmark.deleteImage(context)
  bookmarkDao.deleteBookmark(bookmark)
}
fun deleteBookmark(bookmarkDetailsView: BookmarkDetailsView) {
  GlobalScope.launch {
    val bookmark = bookmarkDetailsView.id?.let {
      bookmarkRepo.getBookmark(it)
    }
    bookmark?.let {
      bookmarkRepo.deleteBookmark(it)
    }
  }
}
private fun deleteBookmark()
{
  val bookmarkView = bookmarkDetailsView ?: return

  AlertDialog.Builder(this)
      .setMessage("Delete?")
      .setPositiveButton("Ok") { _, _ ->
        bookmarkDetailsViewModel.deleteBookmark(bookmarkView)
        finish()
      }
      .setNegativeButton("Cancel", null)
      .create().show()
}
R.id.action_delete -> {
  deleteBookmark()
  return true
}
fun mapBookmarkToBookmarkView(bookmarkId: Long) {
  val bookmark = bookmarkRepo.getLiveBookmark(bookmarkId)
  bookmarkDetailsView = Transformations.map(bookmark) { repoBookmark ->
    repoBookmark?.let { repoBookmark ->
      bookmarkToBookmarkView(repoBookmark)
    }
  }
}

Sharing bookmarks

Your users have painstakingly bookmarked some fantastic places, so why not let them share their good finds with friends?

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) {
return BookmarkDetailsView(
    bookmark.id,
    bookmark.name,
    bookmark.phone,
    bookmark.address,
    bookmark.notes,
    bookmark.category,
    bookmark.longitude,
    bookmark.latitude,
    bookmark.placeId
)
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)
}
<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"/>
private fun setupFab() {
  databinding.fab.setOnClickListener { sharePlace() }
}
setupFab()

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.

<color name="colorPrimary">#3748AC</color>
<color name="colorPrimaryDark">#2A3784</color>
<color name="colorAccent">#E3A60B</color>

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.

<ProgressBar
    android:id="@+id/progressBar"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_gravity="center"
    android:visibility="gone"/>
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)
}
private fun showProgress() {
  databinding.mainMapView.progressBar.visibility = ProgressBar.VISIBLE
  disableUserInteraction()
}

private fun hideProgress() {
  databinding.mainMapView.progressBar.visibility = ProgressBar.GONE
  enableUserInteraction()
}
showProgress()
showProgress()
hideProgress()
hideProgress()
hideProgress()

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.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2024 Kodeco Inc.

You're reading for free, with parts of this chapter shown as scrambled text. Unlock this book, and our entire catalogue of books and videos, with a Kodeco Personal Plan.

Unlock now