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

22. Podcast Details
Written by Tom Blankenship

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

Now that the user can find their favorite podcasts, you’re ready to add a podcast detail screen. In this chapter, you’ll complete the following:

  1. Design and build the podcast detail Fragment.
  2. Expand on the app architecture.
  3. Add a podcast detail Fragment.

Getting started

If you’re following along with your own project, open it and keep using it with this chapter. If not, don’t worry. Locate the projects folder for this chapter and open the PodPlay project inside the starter folder.

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

You’ll start by designing a Layout for the podcast detail screen. The purpose of the detail screen is to give the user a quick overview of the podcast, including the title, description, album art and a list of recent episodes. It will also provide a subscribe action.

The Layout will contain the album art and title at the top, a scrollable description below that and a list of episodes below the description. Each episode will contain the title, description, published date and length. The final Layout will look like this:

Rather than define a new Activity for the podcast detail, you’ll use a Fragment to swap out the main podcast listing View with the podcast detail View. The advantage of using Fragments will become more evident as you build out the full user interface in later chapters.

Defining the Layouts

Create a new Layout and name it fragment_podcast_details.xml. Replace the contents with the following:

<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

  <androidx.constraintlayout.widget.ConstraintLayout
      android:id="@+id/headerView"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:background="#eeeeee"
      android:maxHeight="300dp"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintStart_toStartOf="parent"
      app:layout_constraintTop_toTopOf="parent">

    <ImageView
        android:id="@+id/feedImageView"
        android:layout_width="60dp"
        android:layout_height="60dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="8dp"
        android:src="@android:drawable/ic_menu_report_image"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"/>

    <TextView
        android:id="@+id/feedTitleTextView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginEnd="8dp"
        android:layout_marginStart="8dp"
        android:maxHeight="100dp"
        android:text=""
        android:textSize="14sp"
        android:textStyle="bold"
        app:layout_constraintBottom_toBottomOf="@+id/feedImageView"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toEndOf="@+id/feedImageView"
        app:layout_constraintTop_toTopOf="@+id/feedImageView"/>

    <TextView
        android:id="@+id/feedDescTextView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginEnd="8dp"
        android:layout_marginStart="8dp"
        android:layout_marginTop="4dp"
        android:maxHeight="100dp"
        android:paddingBottom="8dp"
        android:scrollbars="vertical"
        android:text=""
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/feedImageView"/>

  </androidx.constraintlayout.widget.ConstraintLayout>

  <androidx.recyclerview.widget.RecyclerView
      android:id="@+id/episodeRecyclerView"
      android:layout_width="0dp"
      android:layout_height="0dp"
      android:layout_marginEnd="8dp"
      android:layout_marginStart="8dp"
      app:layout_constraintBottom_toBottomOf="parent"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintStart_toStartOf="parent"
      app:layout_constraintTop_toBottomOf="@+id/headerView"
      />

</androidx.constraintlayout.widget.ConstraintLayout>
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:layout_marginBottom="8dp"
    android:layout_marginTop="8dp"
    >
  <TextView
      android:id="@+id/titleView"
      android:layout_width="0dp"
      android:layout_height="wrap_content"
      android:layout_gravity="top"
      android:layout_marginEnd="0dp"
      android:textStyle="bold"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintHorizontal_chainStyle="spread"
      app:layout_constraintStart_toStartOf="parent"
      app:layout_constraintTop_toTopOf="parent"
      tools:text="Title"/>
  <TextView
      android:id="@+id/releaseDateView"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:layout_gravity="top"
      android:layout_marginTop="4dp"
      app:layout_constraintStart_toStartOf="parent"
      app:layout_constraintTop_toBottomOf="@+id/descView"
      tools:text="01/01/18"/>
  <TextView
      android:id="@+id/durationView"
      android:layout_width="wrap_content"
      android:layout_height="wrap_content"
      android:layout_gravity="top"
      android:layout_marginTop="4dp"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintTop_toBottomOf="@+id/descView"
      tools:text="00:00"/>
  <TextView
      android:id="@+id/descView"
      android:layout_width="match_parent"
      android:layout_height="wrap_content"
      android:layout_gravity="top"
      android:layout_marginTop="4dp"
      android:maxLines="3"
      app:layout_constraintEnd_toEndOf="parent"
      app:layout_constraintStart_toStartOf="parent"
      app:layout_constraintTop_toBottomOf="@+id/titleView"
      tools:text="Description"/>
</androidx.constraintlayout.ConstraintLayout>
<FrameLayout
    android:id="@+id/podcastDetailsContainer"
    android:layout_width="0dp"
    android:layout_height="0dp"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@id/app_bar"/>

Basic architecture

As in previous chapters, you need to define the basic architecture components consisting of a repository, a service and a view model to display the podcast detail. There’s no need for any database layer at this point.

Podcast models

To store the podcast data, you need two models: One defines the detail for a single podcast episode, and the other is the podcast detail containing a list of episode models.

data class Episode (
    var guid: String = "",
    var title: String = "",
    var description: String = "",
    var mediaUrl: String = "",
    var mimeType: String = "",
    var releaseDate: Date = Date(),
    var duration: String = ""
)
data class Podcast(
    var feedUrl: String = "",
    var feedTitle: String = "",
    var feedDesc: String = "",
    var imageUrl: String = "",
    var lastUpdated: Date = Date(),
    var episodes: List<Episode> = listOf()
)

Podcast repository

You’ll use a repo for retrieving the podcast details and returning it to the view model.

class PodcastRepo {
  fun getPodcast(feedUrl: String,
      callback: (Podcast?) -> Unit) {
    callback(
        Podcast(feedUrl, "No Name", "No description", "No image")
    )
  }
}

Podcast view model

Inside viewmodel, create a new file and name it PodcastViewModel.kt. Replace the contents with the following:

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

  var podcastRepo: PodcastRepo? = null
  var activePodcastViewData: PodcastViewData? = null

  data class PodcastViewData(
      var subscribed: Boolean = false,
      var feedTitle: String? = "",
      var feedUrl: String? = "",
      var feedDesc: String? = "",
      var imageUrl: String? = "",
      var episodes: List<EpisodeViewData>
  )

  data class EpisodeViewData (
      var guid: String? = "",
      var title: String? = "",
      var description: String? = "",
      var mediaUrl: String? = "",
      var releaseDate: Date? = null,
      var duration: String? = ""
  )
}
private fun episodesToEpisodesView(episodes: List<Episode>):
    List<EpisodeViewData> {
  return episodes.map {
    EpisodeViewData(it.guid, it.title, it.description,
        it.mediaUrl, it.releaseDate, it.duration)
  }
}
private fun podcastToPodcastView(podcast: Podcast):
    PodcastViewData {
  return PodcastViewData(
      false,
      podcast.feedTitle,
      podcast.feedUrl,
      podcast.feedDesc,
      podcast.imageUrl,
      episodesToEpisodesView(podcast.episodes)
  )
}
// 1
fun getPodcast(podcastSummaryViewData: PodcastSummaryViewData,
    callback: (PodcastViewData?) -> Unit) {
  // 2
  val repo = podcastRepo ?: return
  val feedUrl = podcastSummaryViewData.feedUrl ?: return
  // 3
  repo.getPodcast(feedUrl) {
    // 4
    it?.let {
      // 5    
      it.feedTitle = podcastSummaryViewData.name ?: ""
      // 6
      it.imageUrl = podcastSummaryViewData.imageUrl ?: ""
      // 7
      activePodcastViewData = podcastToPodcastView(it)
      // 8
      callback(activePodcastViewData)
    }
  }
}

Details Fragment

The detail Fragment is responsible for displaying the podcast details; it gets its data from PodcastViewModel. This is also where the user can subscribe to a podcast. First, you need to add an action menu with a single Subscribe item.

<string name="subscribe">Subscribe</string>
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android"
      xmlns:app="http://schemas.android.com/apk/res-auto">
  <item
      android:id="@+id/menu_feed_action"
      android:title="@string/subscribe"
      app:showAsAction="ifRoom"
      />
</menu>
class PodcastDetailsFragment : Fragment() {

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    // 1
    setHasOptionsMenu(true)
  }

  override fun onCreateView(inflater: LayoutInflater, 
    container: ViewGroup?, savedInstanceState: Bundle?): 
      View? {
    return inflater.inflate(R.layout.fragment_podcast_details, 
      container, false)
  }

  override fun onActivityCreated(savedInstanceState: Bundle?) {
    super.onActivityCreated(savedInstanceState)
  }

  // 2  
  override fun onCreateOptionsMenu(menu: Menu, 
      inflater: MenuInflater) {
    super.onCreateOptionsMenu(menu, inflater)
    inflater.inflate(R.menu.menu_details, menu)  
  }
}
implementation "androidx.fragment:fragment-ktx:1.2.2"
private val podcastViewModel: PodcastViewModel by activityViewModels()
private fun updateControls() {
  val viewData = podcastViewModel.activePodcastViewData ?:
      return
  feedTitleTextView.text = viewData.feedTitle
  feedDescTextView.text = viewData.feedDesc
  activity?.let { activity ->
    Glide.with(activity).load(viewData.imageUrl)
      .into(feedImageView)
  }
}
updateControls()
companion object {
  fun newInstance(): PodcastDetailsFragment {
    return PodcastDetailsFragment()
  }
}

Displaying details

Now it’s time to show the Fragment. Jump over to PodcastActivity and wire it up.

companion object {
  private const val TAG_DETAILS_FRAGMENT = "DetailsFragment"
}
private fun createPodcastDetailsFragment():
    PodcastDetailsFragment {
  // 1
  var podcastDetailsFragment = supportFragmentManager
      .findFragmentByTag(TAG_DETAILS_FRAGMENT) as
      PodcastDetailsFragment?

  // 2
  if (podcastDetailsFragment == null) {
    podcastDetailsFragment =
        PodcastDetailsFragment.newInstance()
  }

  return podcastDetailsFragment
}
private lateinit var searchMenuItem: MenuItem
searchMenuItem = menu.findItem(R.id.search_item)
val searchView = searchMenuItem.actionView as SearchView
private fun showDetailsFragment() {
  // 1
  val podcastDetailsFragment = createPodcastDetailsFragment()
  // 2
  supportFragmentManager.beginTransaction().add(
      R.id.podcastDetailsContainer,
      podcastDetailsFragment, TAG_DETAILS_FRAGMENT)
          .addToBackStack("DetailsFragment").commit()
  // 3
  podcastRecyclerView.visibility = View.INVISIBLE
  // 4
  searchMenuItem.isVisible = false
}
if (podcastRecyclerView.visibility == View.INVISIBLE) {
  searchMenuItem.isVisible = false
}
private fun showError(message: String) {
  AlertDialog.Builder(this)
      .setMessage(message)
      .setPositiveButton(getString(R.string.ok_button), null)
      .create()
      .show()
}
<string name="ok_button">OK</string>
private val podcastViewModel by viewModels<PodcastViewModel>()
podcastViewModel.podcastRepo = PodcastRepo()
override fun onShowDetails(podcastSummaryViewData:
    SearchViewModel.PodcastSummaryViewData) {
  // 1
  val feedUrl = podcastSummaryViewData.feedUrl ?: return
  // 2
  showProgressBar()
  // 3
  podcastViewModel.getPodcast(podcastSummaryViewData) {
    // 4
    hideProgressBar()
    if (it != null) {
      // 5
      showDetailsFragment()
    } else {
      // 6
      showError("Error loading feed $feedUrl")
    }
  }
}

private fun addBackStackListener()
{
  supportFragmentManager.addOnBackStackChangedListener {
    if (supportFragmentManager.backStackEntryCount == 0) {
      podcastRecyclerView.visibility = View.VISIBLE
    }
  }
}
addBackStackListener()

if (supportFragmentManager.backStackEntryCount > 0) {
  podcastRecyclerView.visibility = View.INVISIBLE
}

Where to go from here?

Congratulations, you made a lot of progress, but the detail screen is still missing some key information, including the list of podcast episodes and the ability to subscribe to the podcast.

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