Chapters

Hide chapters

Android Apprentice

Third Edition · Android 10 · Kotlin 1.3 · Android Studio 3.6

Before You Begin

Section 0: 4 chapters
Show chapters Hide chapters

Section III: Creating Map-Based Apps

Section 3: 7 chapters
Show chapters Hide chapters

17. Detail Activity
Written by Tom Blankenship

In this chapter, you’ll add the ability to edit bookmarks. This involves creating a new Activity to display the bookmark details with editable fields.

Getting started

If you’re following along with your own app, open it and copy res/drawable/ic_action_done.png from the starter project into your project. Also, make sure to copy the files from all of the drawable folders, including everything with the .hdpi, .mdpi, .xhdpi and .xxhdpi extensions.

If you want to use the starter project instead, locate the projects folder for this chapter and open the PlaceBook app inside the starter folder. If you do use the starter app, don’t forget to add your google_maps_key in google_maps_api.xml. Read Chapter 13 for more details about the Google Maps key.

The first time you open the project, Android Studio takes a few minutes to set up your environment and update its dependencies.

Fixing the info window

Before moving on, you need to track down and fix that pesky bug left over from the previous chapter where the app crashes when tapping on a blue marker. The desired behavior is:

  • If the user taps a new place, it shows a red marker and the Info window. If the user then taps on the Info window, you save a bookmark to the database and the marker turns blue.

  • If the user taps on a blue marker, it displays the saved bookmark info, including the image.

Build and run the app again, and tap on an existing bookmark icon. After the app crashes, look at Logcat. Find for the most recent stack trace line that has your app’s package name, and you’ll find the following line:

com.raywenderlich.placebook.adapter.BookmarkInfoWindowAdapter.getInfoContents

This error is a ClassCastException error informing you that a BookmarkMarkerView cannot be cast to a MapActivityPlaceInfo class.

To find out what’s going on, click the blue link for BookmarkAdapter.kt, which takes you to the offending line of code:

imageView.setImageBitmap(
    (marker.tag as MapsActivity.PlaceInfo).image)

The problem is that this code assumes that marker.tag contains an object of type MapsActivity.PlaceInfo. However, that’s not always the case because a marker can now represent two types of places: one is a temporary place that isn’t bookmarked yet, and the other is a place that has an existing bookmark.

To fix this, you need to update the code to take a different action based on the marker tag type.

Open BookmarkInfoWindowAdapter.kt and replace the line in getInfoContents() that calls setImageBitmap() with the following:

when (marker.tag) {
  // 1
  is MapsActivity.PlaceInfo -> {
    imageView.setImageBitmap(
        (marker.tag as MapsActivity.PlaceInfo).image)
  }
  // 2  
  is MapsViewModel.BookmarkMarkerView -> {
    var bookMarkview = marker.tag as
        MapsViewModel.BookmarkMarkerView
    // Set imageView bitmap here
  }
}

The when statement is used to run conditional code based on the class type of marker.tag.

  1. If marker.tag is a MapsActivity.PlaceInfo, you set the imageView bitmap directly from the PlaceInfo.image object.

  2. If marker.tag is a MapsViewModel.BookmarkMarkerView, you set the imageView bitmap from the BookmarkMarkerView.

The only problem is that BookmarkMarkerView doesn’t contain a bookmark image because you haven’t saved images with the bookmarks yet.

Saving an image

Although you can add an image directly to the Bookmark model class and let the Room library save it to the database, it’s not best practice to store large chunks of data in the database. A better method is to store the image as a file that’s linked to the record in the database.

Android doesn’t provide a simple way to save images to a file, so you first need to create a new image utility class, and add a method to save an image to a file.

In the Project navigator, click java/com.raywenderlich.placebook, select File ▸ New ▸ Package and create a new package named util. Inside util, create a new Kotlin class named ImageUtils.kt.

Replace the contents of ImageUtils.kt with the following:

// 1
object ImageUtils {
  // 2
  fun saveBitmapToFile(context: Context, bitmap: Bitmap,
      filename: String) {      
    // 3
    val stream = ByteArrayOutputStream()
    // 4
    bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)
    // 5
    val bytes = stream.toByteArray()
    // 6
    ImageUtils.saveBytesToFile(context, bytes, filename)
  }
  // 7
  private fun saveBytesToFile(context: Context, bytes:
      ByteArray, filename: String) {
    val outputStream: FileOutputStream
    // 8
    try {    
      // 9
      outputStream = context.openFileOutput(filename,
          Context.MODE_PRIVATE)
      // 10
      outputStream.write(bytes)
      outputStream.close()
    } catch (e: Exception) {
      e.printStackTrace()
    }
  }
}

Here’s the code breakdown:

  1. ImageUtils is declared as an object, so it behaves like a singleton. This lets you directly call the methods within ImageUtils without creating a new ImageUtils object each time.

  2. saveBitmapToFile() takes in a Context, Bitmap and String object filename, and saves the Bitmap to permanent storage.

  3. ByteArrayOutputStream is created to hold the image data.

  4. You write the image bitmap to the stream object using the lossless PNG format. Note that the second parameter is a quality setting, but it’s ignored for the PNG format.

  5. the stream is converted into an array of bytes.

  6. saveBytesToFile() is called to write the bytes to a file.

  7. saveBytesToFile() takes in a Context, ByteArray, and a String object filename and saves the bytes to a file.

  8. The next few calls may throw exceptions, so they’re wrapped in a try/catch to prevent a crash.

  9. openFileOutput is used to open a FileOutputStream using the given filename. The Context.MODE_PRIVATE flag causes the file to be written in the private area where only the PlaceBook app can access it.

  10. The bytes are written to the outputStream and then the stream is closed.

Now that you have saveBitmapToFile() set up, you can give the Bookmark object the ability to save a bitmap image for itself. This method will automatically generate a filename for the bitmap that matches the bookmark ID.

Open Bookmark.kt inside model and add the following code to the bottom of the file:

{
  // 1
  fun setImage(image: Bitmap, context: Context) {
    // 2
    id?.let {
      ImageUtils.saveBitmapToFile(context, image,
          generateImageFilename(it))
    }
  }
  //3
  companion object {
    fun generateImageFilename(id: Long): String {
      // 4
      return "bookmark$id.png"
    }
  }
}

Previously Bookmark was a simple data class with only default functions provided by Kotlin. This adds a body to the class.

  1. setImage() provides the public interface for saving an image for a Bookmark.
  2. If the bookmark has an id, then the image gets saved to a file. The filename incorporates the bookmark ID to make sure it’s unique.
  3. generateImageFilename() is placed in a companion object so it’s available at the class level. This allows another object to load an image without having to load the bookmark from the database.
  4. generateImageFilename() returns a filename based on a Bookmark ID. It uses a simple algorithm that appends the bookmark ID to the word “bookmark”. For example, for bookmark ID 5, the associated image is named bookmark5.png. Since you can always infer the bookmark image filename from the bookmark ID, there’s no need to save the filename as a separate field in the database.

Adding the image to the bookmark

Next, you need to set the image for a bookmark when it’s added to the database.

Open MapsViewModel.kt and add the following line in addBookmarkFromPlace() after the call to set bookmarkRepo.addBookmark().

image?.let { bookmark.setImage(it, getApplication()) }

Here, you update addBookmarkFromPlace() to call the new setImage() method if the image is not null.

It’s important to call this after the bookmark is saved to the database so that the bookmark has a unique ID assigned.

setImage() is used to save the image to the bookmark, and the application context is passed into setImage() using getApplication().

Simplifying the bookmark process

Before testing this new functionality there’s a small change you can make to simplify the process of adding a new bookmark.

Currently, when selecting a place, a marker is displayed, and then the user has to tap on the marker again to display the info box. This change automatically displays the Info box when showing the marker.

Open MapsActivity.kt and add the following line to the end of displayPoiDisplayStep():

marker?.showInfoWindow()

This instructs the map to display the Info window for the marker.

Build and run the app. Tap on a new place to display a marker and the Info window. Then, tap on the Info window, which triggers a new bookmark for saving — and this time it stores the bitmap image to a file.

Using Device File Explorer

If you want to verify the image was saved and take a peek behind the scenes at how Android stores files, you can use the Device File Explorer in Android Studio. This is a handy tool for working directly with the Android file system.

Click the Device File Explorer tool on the right side of the Android Studio window. If you don’t see it, click View ▸ Tool Windows ▸ Device File Explorer.

In the newly displayed window, select the device on which you’re running PlaceBook, and then navigate to data/data/com.raywenderlich.placebook/files. If the image save worked correctly, you’ll see at least one bookmark?.png image in the directory; double-click on the image to preview it.

Loading an image

It’s time to load the image from a file. This is considerably easier than saving an image because Android provides a method on BitmapFactory for loading images from files.

In ImageUtils.kt add the following method:

fun loadBitmapFromFile(context: Context, filename: String):
    Bitmap? {
  val filePath = File(context.filesDir, filename).absolutePath
  return BitmapFactory.decodeFile(filePath)
}

This method is passed a context and a filename and returns a Bitmap image by loading the image from the specified filename. A File object is used to combine the files directory for the given context with the filename. A filePath is constructed from the absolute path of the File. The BitmapFactory.decodeFile() does the work of loading the image from the file, and the image is returned to the caller.

Updating BookmarkMarkerView

Now that you can load the image from where it’s stored, it’s time to update BookmarkMarkerView to provide the image for the View.

Your first instinct might be to add a new Bitmap object to BookmarkMarkerView and store it alongside the other properties. While this might work fine for a small set of bookmarks, you’ll start eating up a lot of memory if a user has bookmarked hundreds of places. A better solution is to load the images on-demand.

Loading images on-demand

Open MapsViewModel.kt and replace the BookmarkMarkerView data class definition with the following:

data class BookmarkMarkerView(var id: Long? = null,
                              var location: LatLng = LatLng(0.0, 0.0))
{
  fun getImage(context: Context): Bitmap? {
    id?.let {
      return ImageUtils.loadBitmapFromFile(context,
          Bookmark.generateImageFilename(it))
    }
    return null
  }
}

Previously, BookmarkMarkerView was a simple data class with only default functions provided by Kotlin. This adds a body to the class with the new getImage function.

In getImage(), you first check to make sure the BookmarkMarkerView has a valid ID. Then, you call generateImageFilename() and pass in the bookmark ID represented as id. You call loadBitmapFromFile() with the current context and Bookmark image filename, and it returns the resulting Bitmap to the caller.

You need to update the Info window Adapter to load the image when it’s done rendering. First, you need a Context object to load the image. You can take advantage of the fact that the BookmarkInfoWindowAdapter constructor already has a context passed in.

Open BookmarkInfoWindowAdapter.kt and change the constructor to the following:

class BookmarkInfoWindowAdapter(val context: Activity) :
    GoogleMap.InfoWindowAdapter {

The only difference is the addition of the val modifier. This makes context a property so you can use it later to load the image.

Add the following code in getInfoContents() after the comment // Set imageView bitmap here:

imageView.setImageBitmap(bookMarkview.getImage(context))

Build and run the app. Tap on a blue marker for a bookmark that was saved after you added the ability to save images.

This displays the Info window with the bookmark image.

The image is showing, but there’s no bookmark information along with it. You can fix this by adding the bookmark name and phone number to BookmarkMarkerView.

Updating the Info window

Open MapsViewModel.kt and update the BookmarkMarkerView declaration to match the following:

data class BookmarkMarkerView(
    var id: Long? = null,
    var location: LatLng = LatLng(0.0, 0.0),
    var name: String = "",
    var phone: String = "") {

Note: Make sure to only replace the declaration of BookmarkMarkerView, not the whole class.

This adds new properties for name and phone to BookmarkMarkerView.

Update bookmarkToMarkerView() to match the following:

private fun bookmarkToMarkerView(bookmark: Bookmark):
    MapsViewModel.BookmarkMarkerView {
  return MapsViewModel.BookmarkMarkerView(
      bookmark.id,
      LatLng(bookmark.latitude, bookmark.longitude),
      bookmark.name,
      bookmark.phone)
}

The only change is that the bookmark name and phone properties are passed into the new BookmarkMarkerView constructor.

Open MapsActivity.kt. In addPlaceMarker(), update the call to map.addMarker() with the following:

val marker = map.addMarker(MarkerOptions()
    .position(bookmark.location)
    .title(bookmark.name)
    .snippet(bookmark.phone)
    .icon(BitmapDescriptorFactory.defaultMarker(
        BitmapDescriptorFactory.HUE_AZURE))
    .alpha(0.8f))

The only change here is that the title and snippet items are set to the bookmark name and phone.

Build and run the app. Tap on a blue marker for a saved bookmark. This time, notice it displays the name and phone number beside the image.

If you tap on the Info window, the app will most likely crash. You’ll fix that soon!

Bookmark detail activity

You’ve waited patiently, and it’s finally time to build out the detail Activity for editing a bookmark. For that, you’ll add a new screen that allows the user to edit key details about the bookmark, along with a custom note. You’ll do this by creating a new Activity that displays when a user taps on an Info window.

Designing the edit screen

Before creating the Activity, let’s go over the screen layout and the main elements that will be incorporated.

The Bookmark Edit Layout
The Bookmark Edit Layout

  • The top of the Activity contains an AppBarLayout.
  • Within the AppBarLayout is a Toolbar.
  • Below the AppBarLayout is another vertical LinearLayout to hold the main list of bookmark items.
  • The first item in the vertical layout is the image view.
  • Below the image view is a series of horizontal LinearLayouts. Each LinearLayout holds the label and edit control for a single item. The weights are set so that the label takes 20% of the Layout width.

Defining styles

First, you need to define some standard styles that are required when using the support library version of the toolbar.

Add the following to res/values/styles.xml:

<style name="AppTheme.NoActionBar">
  <item name="windowActionBar">false</item>
  <item name="windowNoTitle">true</item>
</style>

<style name="AppTheme.AppBarOverlay"
    parent="ThemeOverlay.AppCompat.Dark.ActionBar"/>
<style name="AppTheme.PopupOverlay"
    parent="ThemeOverlay.AppCompat.Light"/>

The NoActionBar style is used to hide the native ActionBar. AppBarOverlay gives the Toolbar Layout a dark theme, and PopupOverlay gives the Toolbar content a light theme.

The bookmark details Activity will contain a list of text labels and fields that all have the same style.

You’ll capitalize on this by defining some styles that you can apply to the labels and fields without repeating information in the Activity Layout definition. This will also make it easier in the future to update the styles of all labels and text fields with a single change.

Add the following to res/values/styles.xml:

<style name="BookmarkLabel">
  <item name="android:layout_width">0dp</item>
  <item name="android:layout_height">wrap_content</item>
  <item name="android:layout_weight">0.2</item>
  <item name="android:layout_gravity">bottom</item>
  <item name="android:layout_marginStart">8dp</item>
  <item name="android:layout_marginLeft">8dp</item>
  <item name="android:layout_marginBottom">4dp</item>
  <item name="android:gravity">bottom</item>
</style>

<style name="BookmarkEditText">
  <item name="android:layout_width">0dp</item>
  <item name="android:layout_weight">0.8</item>
  <item name="android:layout_height">wrap_content</item>
  <item name="android:layout_marginEnd">8dp</item>
  <item name="android:layout_marginRight">8dp</item>
  <item name="android:layout_marginStart">8dp</item>
  <item name="android:layout_marginLeft">8dp</item>
  <item name="android:ems">10</item>
</style>

The BookmarkLabel style defines the attributes for all bookmark labels. BookmarkEditText defines the attributes for all bookmark edit fields.

Creating the details layout

Finally, you need to create the bookmark details Layout based on the design. The Activity will use all of the new styles you just added to the project.

Create a new Layout resource file at res/layout/activity_bookmark_details.xml, and replace its contents with the following:

<?xml version="1.0" encoding="utf-8"?>

<LinearLayout
    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:orientation="vertical"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <com.google.android.material.appbar.AppBarLayout
        android:id="@+id/app_bar"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:fitsSystemWindows="true"
        android:theme="@style/AppTheme.AppBarOverlay">

      <androidx.appcompat.widget.Toolbar
          android:id="@+id/toolbar"
          android:layout_width="match_parent"
          android:layout_height="?attr/actionBarSize"
          app:popupTheme="@style/AppTheme.PopupOverlay"/>

    </com.google.android.material.appbar.AppBarLayout>

    <ImageView
        android:id="@+id/imageViewPlace"
        android:layout_margin="0dp"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:maxHeight="300dp"
        android:scaleType="fitCenter"
        android:adjustViewBounds="true"
        app:srcCompat="@drawable/default_photo"/>

</LinearLayout>

This defines the basic Layout for the bookmark details screen. The Activity is contained within a vertical linear Layout. The Toolbar is defined as the first item in the Layout, and the styles you defined earlier are used to theme the Toolbar. The bookmark image is placed below the Toolbar.

The Layout up to this point looks like this:

Next, you need to add a series of form rows that represent the editable bookmark details. Each of these rows will be represented by a horizontal LinearLayout with a TextView on the left and an EditText element on the right.

First, add a row for the bookmark name by adding the following code below the <ImageView> element:

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginTop="8dp"
    android:orientation="horizontal">

  <TextView
      android:id="@+id/textViewName"
      style="@style/BookmarkLabel"
      android:text="Name"/>

  <EditText
      android:id="@+id/editTextName"
      style="@style/BookmarkEditText"
      android:hint="Name"
      android:inputType="text"
      />
</LinearLayout>

You’re using the BookmarkLabel and BookmarkEditText styles defined earlier to apply the Layout details to the items.

Next, add a row for the bookmark notes by adding the following code after the bookmark name row:

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal">

  <TextView
      android:id="@+id/textViewNotes"
      style="@style/BookmarkLabel"
      android:text="Notes"/>

  <EditText
      android:id="@+id/editTextNotes"
      style="@style/BookmarkEditText"
      android:hint="Enter notes"
      android:inputType="textMultiLine"/>
</LinearLayout>

This repeats the formula used for the name row. The only difference is the inputType is set to allow multiple input lines.

Next, add a row for the bookmark phone number by adding the following code after the bookmark notes row:

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal">

  <TextView
      android:id="@+id/textViewPhone"
      style="@style/BookmarkLabel"
      android:text="Phone"/>

  <EditText
      android:id="@+id/editTextPhone"
      style="@style/BookmarkEditText"
      android:hint="Phone number"
      android:inputType="phone"
      />
</LinearLayout>

Next, add a row for the bookmark address by adding the following code after the bookmark phone number row:

<LinearLayout
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:orientation="horizontal">

  <TextView
      android:id="@+id/textViewAddress"
      style="@style/BookmarkLabel"
      android:text="Address"/>

  <EditText
      android:id="@+id/editTextAddress"
      style="@style/BookmarkEditText"
      android:hint="Address"
      android:inputType="textMultiLine"
      />
</LinearLayout>

The final Layout after adding all of the rows will look like this:

Details activity class

Now that the bookmark details Layout is complete, you can create the details Activity to go along with it.

Inside ui, create a new Kotlin file named BookmarkDetailsActivity.kt, and replace the contents with the following:

class BookmarkDetailsActivity : AppCompatActivity() {
  override fun onCreate(savedInstanceState:
      android.os.Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_bookmark_details)
    setupToolbar()
  }

  private fun setupToolbar() {
    setSupportActionBar(toolbar)
  }
}

This is a fairly standard Activity class that uses the support action bar. setupToolbar() calls the built-in setSupportActionBar() to make the Toolbar act as the ActionBar for this Activity. By using the support library version of the Toolbar, you ensure that it works the same across a variety of devices.

Note: Because the app build.gradle file contains the kotlin-android-extensions plugin, Android Studio automatically recognizes the Toolbar View from the activity_bookmark_details Layout. If you look at the top of the file, you’ll notice that it has included an import kotlinx.android.synthetic.main.activity_bookmark_details.* statement. This import includes the auto synthesized properties for the Views in the Layout.

Support design library

To use setSupportActionBar() you need to include the material design library provided by Google.

In the app build.gradle file, add the following line in the dependencies section: the following:

implementation 'com.google.android.material:material:1.1.0'

This includes the material design library in the app.

Updating the manifest

Next, you need to make Android aware of the new BookmarkDetailsActivity class, so add the Activity to AndroidManifest.xml within the <application> section:

<activity
    android:name=
        "com.raywenderlich.placebook.ui.BookmarkDetailsActivity"
    android:label="Bookmark"
    android:theme="@style/AppTheme.NoActionBar"
    android:windowSoftInputMode="stateHidden">
</activity>

Note that the theme with NoActionBar is required when using the support Toolbar. android:windowSoftInputMode is set to stateHidden to prevent the soft keyboard from displaying when the activity is first displayed.

Starting the details Activity

You can now hook up the new details Activity to the main maps Activity. You’ll detect when the user taps on a bookmark Info window, and then start the details Activity.

Add the following method to MapsActivity.kt:

private fun startBookmarkDetails(bookmarkId: Long) {
  val intent = Intent(this, BookmarkDetailsActivity::class.java)
  startActivity(intent)
}

Here, startBookmarkDetails() is used to start the BookmarkDetailsActivity using an explicit Intent. You’ll call this method when the user taps on an info window for an existing bookmark.

Replace handleInfoWindowClick() with the following:

private fun handleInfoWindowClick(marker: Marker) {
  when (marker.tag) {
    is MapsActivity.PlaceInfo -> {
      val placeInfo = (marker.tag as PlaceInfo)
      if (placeInfo.place != null && placeInfo.image != null) {
        GlobalScope.launch {
          mapsViewModel.addBookmarkFromPlace(placeInfo.place,
              placeInfo.image)
        }
      }
      marker.remove();
    }
    is MapsViewModel.BookmarkMarkerView -> {
      val bookmarkMarkerView = (marker.tag as
          MapsViewModel.BookmarkMarkerView)
      marker.hideInfoWindow()
      bookmarkMarkerView.id?.let {
        startBookmarkDetails(it)
      }
    }
  }
}

This method handles the action when a user taps a place Info window. Previously, it was designed to save the bookmark to the database. Now, it saves the bookmark if it hasn’t been saved before, or it starts the bookmark details Activity if the bookmark has already been saved.

Previously, this method assumed that the marker.tag would always be a PlaceInfo object. Now you’re using the when construct to take a different action based on the marker.tag type. If it’s a BookmarkMarkerView, then the info window is hidden and you start the bookmark details Activity.

Build and run the app. Tap on a blue bookmark marker, and then tap on the Info window. The new bookmark details screen is shown.

This is a good chance to verify the Layout is working before populating the dialog with the actual bookmark content. Everything looks good in portrait, but rotate the device to landscape and you may see something like this:

Whoops! On many Android devices, you’ll only see the image with no way to scroll down and view the edit fields. You can easily fix this by surrounding the main content with a ScrollView.

Open activity_bookmark_details.xml and after </com.google.android.material.appbar.AppBarLayout>, add this:

<ScrollView
    android:layout_width="match_parent"
    android:layout_height="match_parent">

  <LinearLayout
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:orientation="vertical">

Now, add the following closing tags before the last </LinearLayout>:

  </LinearLayout>
</ScrollView>

By enclosing the main content in a ScrollView, you allow the user to scroll to see the entire details form.

Build and run the app again, and display the details for a place. Rotate to landscape mode and scroll the view to see the edit fields.

That looks much better!

Populating the bookmark

The Activity has the general look you want, but it lacks any knowledge about the bookmark. To fix this, you’ll pass the bookmark ID to the Activity so that it can display the bookmark data.

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

const val EXTRA_BOOKMARK_ID =
    "com.raywenderlich.placebook.EXTRA_BOOKMARK_ID"

This defines a key for storing the bookmark ID in the intent extras.

In startBookmarkDetails(), add the following line before the call to startActivity():

intent.putExtra(EXTRA_BOOKMARK_ID, bookmarkId)

This adds the bookmarkId as an extra parameter on the Intent.

Next, you need to retrieve this parameter in the bookmark details Activity, and use it to load the bookmark details.

Open BookmarkRepo.kt and add the following method:

fun getLiveBookmark(bookmarkId: Long): LiveData<Bookmark> {
  val bookmark = bookmarkDao.loadLiveBookmark(bookmarkId)
  return bookmark
}

This method returns a live bookmark from the bookmark DAO.

Just like MapsActivity, BookmarkDetailsActivity uses a ViewModel to coordinate the data between the View and the Model.

You need to create a new View Model class for the details Activity. This class will use the bookmark repo to retrieve the bookmark details and format it for the details Activity.

In viewmodel, create a new Kotlin file named BookmarkDetailsViewModel.kt and replace the contents with the following:

class BookmarkDetailsViewModel(application: Application) :
    AndroidViewModel(application) {    

  private var bookmarkRepo: BookmarkRepo =
      BookmarkRepo(getApplication())
}

BookmarkDetailsViewModel inherits from AndroidViewModel just like the MapsViewModel class. A private BookmarkRepo property is defined and initialized with a new BookmarkRepo instance.

You’ll follow a similar pattern as you did with MapsViewModel to return data for the View. You can repeat this pattern anytime you need to return live data for a View; it can be generalized as follows:

  1. Define a new data class to hold the info required by the View class.
  2. Define a LiveData property with the new data class.
  3. Define a method to transform LiveData model data to LiveData view data.
  4. Define a method to return the view data to the View.

Add the following internal class to BookmarkDetailsViewModel:

data class BookmarkDetailsView(
    var id: Long? = null,
    var name: String = "",
    var phone: String = "",
    var address: String = "",
    var notes: String = ""
) {

  fun getImage(context: Context): Bitmap? {
    id?.let {
      return ImageUtils.loadBitmapFromFile(context,
          Bookmark.generateImageFilename(it))
    }
    return null
  }
}

BookmarkDetailsView defines the data needed by BookmarkDetailsActivity. getImage() loads the image associated with the bookmark.

Adding notes to the database

Before continuing, you need a way to store notes for a bookmark.

Open Bookmark.kt and update the Bookmark declaration to add in the notes property, like so:

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 = ""
)

Now that you’ve changed the Bookmark class, the main database class needs to be made aware of it.

Open PlaceBookDatabase.kt and update the @Database annotation version to 2 as follows:

@Database(entities = arrayOf(Bookmark::class), version = 2)

The change to Bookmark requires a change to the underlying database structure managed by Room. Setting the version to 2 lets Room know that something is different about the database.

The first time the app is launched after updating the version, Room tries to migrate data from the old structure to the new structure. It does so by looking for Migrations that you have added to the database builder. If you haven’t added any Migrations, then an exception is thrown, and the app crashes.

Rather than providing Migrations, you can prevent the crash by telling Room to create the new database from scratch and discard all old data.

In the companion object’s getInstance(), replace the call to Room.databaseBuilder with the following:

instance = Room.databaseBuilder(context.applicationContext,
    PlaceBookDatabase::class.java, "PlaceBook")
    .fallbackToDestructiveMigration()
    .build()

This adds the fallbackToDestructiveMigration() call the builder and tells Room to create a new empty database if it can’t find any Migrations.

Note: If you want to learn how to handle database schema changes using Migrations, please see the official documentation at https://developer.android.com/topic/libraries/architecture/room.html#db-migration.

Bookmark view model

That’s all you need to support the revised Bookmark model in the database. Now you need to convert the database model to a view model.

Go back to BookmarkDetailsViewModel.kt and add the following method to the BookmarkDetailsViewModel class:

private fun bookmarkToBookmarkView(bookmark: Bookmark): BookmarkDetailsView {
  return BookmarkDetailsView(
      bookmark.id,
      bookmark.name,
      bookmark.phone,
      bookmark.address,
      bookmark.notes
  )
}

This method converts a Bookmark model to a BookmarkDetailsView model. Now, you need a property to hold the current bookmark view object.

Add the following to the top of the class:

private var bookmarkDetailsView: LiveData<BookmarkDetailsView>? = null

The bookmarkDetailsView property holds the LiveData<BookmarkDetailsView> object. This allows the View to stay updated anytime the view model changes.

You have defined a method to convert from the database bookmark to the View bookmark, now you need to convert from a live database bookmark object to a live bookmark view object.

Add the following method:

private fun mapBookmarkToBookmarkView(bookmarkId: Long) {
  val bookmark = bookmarkRepo.getLiveBookmark(bookmarkId)
  bookmarkDetailsView = Transformations.map(bookmark) { repoBookmark ->
    bookmarkToBookmarkView(repoBookmark)
  }
}

Here, you get the live Bookmark from the BookmarkRepo and then transform it to the live BookmarkDetailsView. See the previous chapter for details about how Transformations.map() works.

Finally, you can bring it all together by exposing a method to return a live bookmark View based on a bookmark ID. Add the following method:

fun getBookmark(bookmarkId: Long): LiveData<BookmarkDetailsView>? {
  if (bookmarkDetailsView == null) {
    mapBookmarkToBookmarkView(bookmarkId)
  }
  return bookmarkDetailsView
}

getBookmark() returns the BookmarkDetailsView object. If this is the first time getBookmark() is called, mapBookmarkToBookmarkView() is used to create the bookmarkDetailsView, otherwise the previously created bookmarkDetailsView is returned.

Retrieving the bookmark view

You’re ready to add the code to retrieve the BookmarkDetailsView LiveData object in the View Activity.

First, you need some properties to hold the view model data.

Open BookmarkDetailsActivity.kt and add the following properties:

private val bookmarkDetailsViewModel by 
    viewModels<BookmarkDetailsViewModel>()
private var bookmarkDetailsView:
    BookmarkDetailsViewModel.BookmarkDetailsView? = null

by viewModels<BookmarkDetailsViewModel>() creates the bookmarkDetailsViewModel using the viewModels delegate. This is the standard procedure for initializing a view model tat you have seen in earlier chapters.

Add the following method to populate the fields in the View:

private fun populateFields() {
  bookmarkDetailsView?.let { bookmarkView ->
    editTextName.setText(bookmarkView.name)
    editTextPhone.setText(bookmarkView.phone)
    editTextNotes.setText(bookmarkView.notes)
    editTextAddress.setText(bookmarkView.address)
  }
}

This method populates all of the UI fields using the current bookmarkView provided it’s not null.

You can also take the bookmark image from the view model and assign it to the image UI element.

Add the following method:

private fun populateImageView() {
  bookmarkDetailsView?.let { bookmarkView ->
    val placeImage = bookmarkView.getImage(this)
    placeImage?.let {
      imageViewPlace.setImageBitmap(placeImage)
    }
  }
}

This method loads the image from bookmarkView and then uses it to set the imageViewPlace.

Using the intent data

When the user taps on the Info window for a bookmark on the maps Activity, it passes the bookmark ID to the details Activity.

You now need to add a method in BookmarkDetailsActivity to read this Intent data and use it to populate the UI.

Add the following method:

private fun getIntentData() {
  // 1
  val bookmarkId = intent.getLongExtra(
      MapsActivity.Companion.EXTRA_BOOKMARK_ID, 0)  
  // 2    
  bookmarkDetailsViewModel.getBookmark(bookmarkId)?.observe(
      this, Observer<BookmarkDetailsViewModel.BookmarkDetailsView> {
    // 3
    it?.let {
      bookmarkDetailsView = it
      // Populate fields from bookmark
      populateFields()
      populateImageView()
    }
  })
}

Note: If Android Studio gives you multiple choices of imports for the Observer class, make sure to choose import androidx.lifecycle.Observer.

This method is called when the Activity is created. Here’s how it works:

  1. You pull the bookmarkId from the Intent data.

  2. You retrieve the BookmarkDetailsView from BookmarkDetailsViewModel and then observe it for changes.

  3. Whenever the BookmarkDetailsView is loaded or changed, you assign the bookmarkDetailsView property to it, and populate the bookmark fields from the data. You call the previously defined functions to populate the fields.

Finishing the detail activity

You’re ready to pull everything together by adding the following call to the end of onCreate() in BookmarkDetailsActivity.

getIntentData()

When the bookmark details Activity starts, it processes the Intent data passed in from the maps Activity.

Build and run the app. The previous data is cleared out because of the database schema change.

Add a new bookmark and view the details. The bookmark info is now displayed.

Saving changes

The only major feature left is to save the user’s edits. For that, you’ll add a checkmark Toolbar item to trigger the save.

First, you need a menu resource file to define a checkmark.

Create a new menu resource folder using File ▸ New ▸ Android resource directory with a name of menu and a resource type of menu.

Create a new menu resource file named menu_bookmark_details.xml in res/menu and replace the contents with the following:

<?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"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context=
    "com.raywenderlich.placebook.ui.BookmarkDetailsActivity">

  <item
      android:id="@+id/action_save"
      android:icon="@drawable/ic_action_done"
      android:title="Save"
      app:showAsAction="ifRoom"/>
</menu>

This defines a single menu item with an ID of action_save for the detail Activity Toolbar. Now, you need to inflate the menu resource in the details Activity.

Open BookmarkDetailsActivity.kt and add the following method:

override fun onCreateOptionsMenu(menu: android.view.Menu):
    Boolean {
  val inflater = menuInflater
  inflater.inflate(R.menu.menu_bookmark_details, menu)
  return true
}

You override onCreateOptionsMenu and provide items for the Toolbar by loading in the menu_bookmark_details menu.

Note: You may need to terminate any running versions of PlaceBook before the Android Studio will recognize the new menu resource.

To save an updated bookmark to the database, you need some new methods in BookmarkRepo. Open BookmarkRepo.kt and add the following methods:

fun updateBookmark(bookmark: Bookmark) {
  bookmarkDao.updateBookmark(bookmark)
}

fun getBookmark(bookmarkId: Long): Bookmark {
  return bookmarkDao.loadBookmark(bookmarkId)
}

updateBookmark() takes in a bookmark and saves it using the bookmark DAO. getBookmark() takes in a bookmark ID and uses the bookmark DAO to load the corresponding bookmark.

When the user makes changes to a bookmark, you need to update the bookmark view model class. For that, you need a method to convert a bookmark view model to the database bookmark model.

Open BookmarkDetailsViewModel.kt and add the following method:

private fun bookmarkViewToBookmark(bookmarkView: BookmarkDetailsView):
    Bookmark? {
  val bookmark = bookmarkView.id?.let {
    bookmarkRepo.getBookmark(it)
  }
  if (bookmark != null) {
    bookmark.id = bookmarkView.id
    bookmark.name = bookmarkView.name
    bookmark.phone = bookmarkView.phone
    bookmark.address = bookmarkView.address
    bookmark.notes = bookmarkView.notes
  }
  return bookmark
}

This method takes a BookmarkDetailsView and returns a Bookmark with the updated parameters from the BookmarkDetailsView. You load the original bookmark values from the BookmarkRepo before updating them with the BookmarkDetailsView. It’s important to load in the original bookmark to retain the values that aren’t updated by the BookmarkDetailsView.

You can now utilize bookmarkViewToBookmark() to create a new public method to update a bookmark in the background.

Add the following method:

fun updateBookmark(bookmarkView: BookmarkDetailsView) {
  // 1
  GlobalScope.launch {
    // 2
    val bookmark = bookmarkViewToBookmark(bookmarkView)
    // 3
    bookmark?.let { bookmarkRepo.updateBookmark(it) }
  }
}

This method updates the bookmark from a BookmarkDetailsView.

  1. A coroutine is used to run the method in the background. This allows calls to be made by the bookmark repo that access the database.
  2. The BookmarkDetailsView is converted to a Bookmark.
  3. If the bookmark is not null, it’s updated in the bookmark repo. This updates the bookmark in the database.

Now you can modify the bookmark details Activity and make use of the new updateBookmark() method provided by the View model.

Open BookmarkDetailsActivity.kt and add the following method:

private fun saveChanges() {
  val name = editTextName.text.toString()
  if (name.isEmpty()) {
    return
  }
  bookmarkDetailsView?.let { bookmarkView ->
    bookmarkView.name = editTextName.text.toString()
    bookmarkView.notes = editTextNotes.text.toString()
    bookmarkView.address = editTextAddress.text.toString()
    bookmarkView.phone = editTextPhone.text.toString()
    bookmarkDetailsViewModel.updateBookmark(bookmarkView)
  }
  finish()
}

This method takes the current changes from the text fields and updates the bookmark. The method doesn’t do anything if editTextName is blank. After updating the bookmarkView with the data from the EditText fields, updateBookmark() is called to update the bookmark model. Finally, the Activity is closed with the finish() call.

Next, you need to add code to respond to the user tapping the checkmark menu item and then call saveChanges().

Add the following method:

override fun onOptionsItemSelected(item: MenuItem): Boolean {
  when (item.itemId) {
    R.id.action_save -> {
      saveChanges()
      return true
    }
    else -> return super.onOptionsItemSelected(item)
  }
}

This method is called when the user selects a Toolbar checkmark item. You check the item.itemId to see if it matches action_save, and if so, saveChanges() is called.

Build and run the app. Go into the details Activity of an existing bookmark and change some of the data. Tap the checkmark in the Toolbar to save your changes. Now, display the details for the same bookmark, and you’ll see that the data reflects your changes.

Where to go from here?

Congratulations! You can now edit bookmarks, but there’s still more work to do. The next chapter wraps things up by adding some additional features and putting the finishing touches on the app.

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.
© 2026 Kodeco Inc.