Home Android & Kotlin Books Android Apprentice

17
Detail Activity Written by Kevin Moore

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

You can unlock the rest of this book, and our entire catalogue of books and videos, with a raywenderlich.com Professional subscription.

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, copy ic_action_done.png from the starter project in the res/drawable-xxxx/ folders into your project. 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:

com.raywenderlich.placebook.adapter.BookmarkInfoWindowAdapter.getInfoContents

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

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 a 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.

// 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
    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()
    }
  }
}
{
  // 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"
    }
  }
}

Adding the image to the bookmark

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

image?.let { bookmark.setImage(it, 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.

marker?.showInfoWindow()

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.

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.

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

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.

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) = id?.let {
      ImageUtils.loadBitmapFromFile(context, Bookmark.generateImageFilename(it))
  }
}
class BookmarkInfoWindowAdapter(val context: Activity) :
    GoogleMap.InfoWindowAdapter {
imageView.setImageBitmap(bookMarkview.getImage(context))

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 = ""
) {
private fun bookmarkToMarkerView(bookmark: Bookmark) = BookmarkMarkerView(
      bookmark.id,
      LatLng(bookmark.latitude, bookmark.longitude),
      bookmark.name,
      bookmark.phone
)
val marker = map.addMarker(MarkerOptions()
    .position(bookmark.location)
    .title(bookmark.name)
    .snippet(bookmark.phone)
    .icon(BitmapDescriptorFactory.defaultMarker(
        BitmapDescriptorFactory.HUE_AZURE))
    .alpha(0.8f))

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
Hso Suuymatw Iboq Tunair

Introducing Data Binding

Previously Google recommended using Kotlin Android extensions for referring to layout fields in classes. Now Google recommends using either View Binding or Data Binding. From Google:

buildFeatures {
  viewBinding true
  dataBinding true  // add this line
}

Defining styles

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

<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" />
<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>

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.

implementation "androidx.constraintlayout:constraintlayout:2.0.4"
implementation 'com.google.android.material:material:1.2.1'
<string name="name">Name</string>
<string name="address">Address</string>
<string name="phone">Phone</string>
<string name="phone_number">Phone number</string>
<string name="notes">Notes</string>
<string name="enter_notes">Enter notes</string>
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto">
  <androidx.coordinatorlayout.widget.CoordinatorLayout    
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <com.google.android.material.appbar.AppBarLayout
      android:id="@+id/appbar"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:theme="@style/AppTheme.AppBarOverlay">
        <com.google.android.material.appbar.MaterialToolbar
          android:layout_width="match_parent"
          android:layout_height="match_parent"
          app:contentScrim="?attr/colorPrimary"
          app:layout_scrollFlags="scroll|exitUntilCollapsed"
          app:toolbarId="@+id/toolbar">
          <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.MaterialToolbar>
    </com.google.android.material.appbar.AppBarLayout>

    <androidx.core.widget.NestedScrollView
      android:layout_width="match_parent"
      android:layout_height="match_parent"                                           app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior">

      <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <androidx.appcompat.widget.AppCompatImageView
          android:id="@+id/imageViewPlace"
          android:layout_width="0dp"
          android:layout_height="wrap_content"
          android:adjustViewBounds="true"
          android:maxHeight="300dp"
          android:scaleType="fitCenter"
          app:layout_constraintEnd_toEndOf="parent"
          app:layout_constraintStart_toStartOf="parent"
          app:layout_constraintTop_toTopOf="parent"
          app:srcCompat="@drawable/default_photo" />
      </androidx.constraintlayout.widget.ConstraintLayout>
    </androidx.core.widget.NestedScrollView>
  </androidx.coordinatorlayout.widget.CoordinatorLayout>
</layout>

<androidx.appcompat.widget.AppCompatTextView
  android:id="@+id/textViewName"
  style="@style/BookmarkLabel"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:text="@string/name"
  android:layout_marginTop="16dp"
  app:layout_constraintBaseline_toBaselineOf="@+id/editTextName"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintTop_toBottomOf="@+id/imageViewPlace" />

<androidx.appcompat.widget.AppCompatTextView
  android:id="@+id/textViewNotes"
  style="@style/BookmarkLabel"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:inputType="textMultiLine"
  android:text="@string/notes"
  app:layout_constraintBaseline_toBaselineOf="@+id/editTextNotes"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintTop_toBottomOf="@+id/textViewName" />

<androidx.appcompat.widget.AppCompatTextView
  android:id="@+id/textViewPhone"
  style="@style/BookmarkLabel"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:text="@string/phone"
  app:layout_constraintBaseline_toBaselineOf="@+id/editTextPhone"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintTop_toBottomOf="@+id/textViewNotes" />

<androidx.appcompat.widget.AppCompatTextView
  android:id="@+id/textViewAddress"
  style="@style/BookmarkLabel"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  android:text="@string/address"
  app:layout_constraintBaseline_toBaselineOf="@+id/editTextAddress"
  app:layout_constraintEnd_toStartOf="@+id/barrier1"
  app:layout_constraintStart_toStartOf="parent"
  app:layout_constraintTop_toBottomOf="@+id/textViewPhone" />

<androidx.constraintlayout.widget.Barrier
  android:id="@+id/barrier1"
  android:layout_width="wrap_content"
  android:layout_height="wrap_content"
  app:barrierDirection="start"
app:constraint_referenced_ids="editTextName, editTextNotes,editTextPhone, editTextAddress" />

<com.google.android.material.textfield.TextInputEditText
  android:id="@+id/editTextName"
  style="@style/BookmarkEditText"
  android:layout_width="0dp"
  android:layout_height="wrap_content"
  android:hint="@string/name"
  android:layout_marginTop="16dp"
  android:layout_marginStart="16dp"
  app:layout_constraintEnd_toEndOf="parent"
  app:layout_constraintStart_toEndOf="@+id/barrier1"
  app:layout_constraintTop_toBottomOf="@+id/imageViewPlace" />

<com.google.android.material.textfield.TextInputEditText
  android:id="@+id/editTextNotes"
  style="@style/BookmarkEditText"
  android:layout_width="0dp"
  android:layout_height="wrap_content"
  android:hint="@string/enter_notes"
  android:layout_marginStart="16dp"
  app:layout_constraintEnd_toEndOf="parent"
  app:layout_constraintStart_toEndOf="@+id/barrier1"
  app:layout_constraintTop_toBottomOf="@+id/editTextName" />

<com.google.android.material.textfield.TextInputEditText
  android:id="@+id/editTextPhone"
  style="@style/BookmarkEditText"
  android:layout_width="0dp"
  android:layout_height="wrap_content"
  android:hint="@string/phone_number"
  android:layout_marginStart="16dp"
  app:layout_constraintEnd_toEndOf="parent"
  app:layout_constraintStart_toEndOf="@+id/barrier1"
  app:layout_constraintTop_toBottomOf="@+id/editTextNotes" />

<com.google.android.material.textfield.TextInputEditText
  android:id="@+id/editTextAddress"
  style="@style/BookmarkEditText"
  android:layout_width="0dp"
  android:layout_height="wrap_content"
  android:hint="@string/address"
  android:inputType="textMultiLine"
  android:layout_marginStart="16dp"
  app:layout_constraintEnd_toEndOf="parent"
  app:layout_constraintStart_toEndOf="@+id/barrier1"
  app:layout_constraintTop_toBottomOf="@+id/editTextPhone" />

Details activity class

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

class BookmarkDetailsActivity : AppCompatActivity() {
  private lateinit var databinding: ActivityBookmarkDetailsBinding
  
  override fun onCreate(savedInstanceState: android.os.Bundle?) {
    super.onCreate(savedInstanceState)
    databinding = DataBindingUtil.setContentView(this, R.layout.activity_bookmark_details)
    setupToolbar()
  }

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

Updating the manifest

Next, open AndroidManifest.xml and replace the BookmarkDetailsActivity activity with:

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

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.

private fun startBookmarkDetails(bookmarkId: Long) {
  val intent = Intent(this, BookmarkDetailsActivity::class.java)
  startActivity(intent)
}
private fun handleInfoWindowClick(marker: Marker) {
  when (marker.tag) {
    is 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)
      }
    }
  }
}

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.

const val EXTRA_BOOKMARK_ID =
    "com.raywenderlich.placebook.EXTRA_BOOKMARK_ID"
intent.putExtra(EXTRA_BOOKMARK_ID, bookmarkId)
fun getLiveBookmark(bookmarkId: Long): LiveData<Bookmark> = 
  bookmarkDao.loadLiveBookmark(bookmarkId)
class BookmarkDetailsViewModel(application: Application) : AndroidViewModel(application) {
  private val bookmarkRepo = BookmarkRepo(getApplication())
}
data class BookmarkDetailsView(
    var id: Long? = null,
    var name: String = "",
    var phone: String = "",
    var address: String = "",
    var notes: String = ""
) {

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

Adding the data tag

Now that you have created the BookmarkDetailsView class, you can add a variable to the activity_bookmark_details.xml file. Right after the <layout> tag add:

<data>
  <variable
    name="bookmarkDetailsView"
    type="com.raywenderlich.placebook.viewmodel.BookmarkDetailsViewModel.BookmarkDetailsView" />
</data>
android:text="@{bookmarkDetailsView.name}"
android:text="@{bookmarkDetailsView.notes}"
android:text="@{bookmarkDetailsView.phone}"
android:text="@{bookmarkDetailsView.address}"

Adding notes to the database

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

var notes: 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 = ""
)
@Database(entities = arrayOf(Bookmark::class), version = 2)
instance = Room.databaseBuilder(context.applicationContext,
    PlaceBookDatabase::class.java, "PlaceBook")
    .fallbackToDestructiveMigration()
    .build()

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.

private fun bookmarkToBookmarkView(bookmark: Bookmark): BookmarkDetailsView {
  return BookmarkDetailsView(
      bookmark.id,
      bookmark.name,
      bookmark.phone,
      bookmark.address,
      bookmark.notes
  )
}
private var bookmarkDetailsView: LiveData<BookmarkDetailsView>? = null
private fun mapBookmarkToBookmarkView(bookmarkId: Long) {
  val bookmark = bookmarkRepo.getLiveBookmark(bookmarkId)
  bookmarkDetailsView = Transformations.map(bookmark) { repoBookmark ->
    bookmarkToBookmarkView(repoBookmark)
  }
}
fun getBookmark(bookmarkId: Long): LiveData<BookmarkDetailsView>? {
  if (bookmarkDetailsView == null) {
    mapBookmarkToBookmarkView(bookmarkId)
  }
  return bookmarkDetailsView
}

Retrieving the bookmark view

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

private val bookmarkDetailsViewModel by 
    viewModels<BookmarkDetailsViewModel>()
private var bookmarkDetailsView:
    BookmarkDetailsViewModel.BookmarkDetailsView? = null
import androidx.activity.viewModels
private fun populateImageView() {
  bookmarkDetailsView?.let { bookmarkView ->
    val placeImage = bookmarkView.getImage(this)
    placeImage?.let {
      databinding.imageViewPlace.setImageBitmap(placeImage)
    }
  }
}

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.

private fun getIntentData() {
  // 1
  val bookmarkId = intent.getLongExtra(
      MapsActivity.Companion.EXTRA_BOOKMARK_ID, 0)  
  // 2    
  bookmarkDetailsViewModel.getBookmark(bookmarkId)?.observe(this, {
    // 3
    it?.let {
      bookmarkDetailsView = it
      // 4
      databinding.bookmarkDetailsView = it
      populateImageView()
    }
  })
}

Finishing the detail activity

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

getIntentData()

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.

<?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>
override fun onCreateOptionsMenu(menu: android.view.Menu): Boolean {
  menuInflater.inflate(R.menu.menu_bookmark_details, menu)
  return true
}
fun updateBookmark(bookmark: Bookmark) {
  bookmarkDao.updateBookmark(bookmark)
}

fun getBookmark(bookmarkId: Long): Bookmark {
  return bookmarkDao.loadBookmark(bookmarkId)
}
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
}
fun updateBookmark(bookmarkView: BookmarkDetailsView) {
  // 1
  GlobalScope.launch {
    // 2
    val bookmark = bookmarkViewToBookmark(bookmarkView)
    // 3
    bookmark?.let { bookmarkRepo.updateBookmark(it) }
  }
}
private fun saveChanges() {
  val name = databinding.editTextName.text.toString()
  if (name.isEmpty()) {
    return
  }
  bookmarkDetailsView?.let { bookmarkView ->
    bookmarkView.name = databinding.editTextName.text.toString()
    bookmarkView.notes = databinding.editTextNotes.text.toString()
    bookmarkView.address = databinding.editTextAddress.text.toString()
    bookmarkView.phone = databinding.editTextPhone.text.toString()
    bookmarkDetailsViewModel.updateBookmark(bookmarkView)
  }
  finish()
}
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
  R.id.action_save -> {
    saveChanges()
    true
  }
  else -> super.onOptionsItemSelected(item)
}

Key Points

Placebook is starting to take shape. In this chapter you learned:

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.

Have feedback to share about the online reading experience? If you have feedback about the UI, UX, highlighting, or other features of our online readers, you can send them to the design team with the form below:

© 2021 Razeware LLC

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 raywenderlich.com Professional subscription.

Unlock Now

To highlight or take notes, you’ll need to own this book in a subscription or purchased by itself.