Final Results

We changed a lot of stuff in this tutorial!

The nav_graph navigation resource should contain:

<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
  xmlns:app="http://schemas.android.com/apk/res-auto"
  android:id="@+id/nav_graph.xml"
  app:startDestination="@id/rosterListFragment">

  <fragment
    android:id="@+id/rosterListFragment"
    android:name="com.commonsware.todo.RosterListFragment"
    android:label="@string/app_name">
    <action
      android:id="@+id/displayModel"
      app:destination="@id/displayFragment" />
  </fragment>
  <fragment
    android:id="@+id/displayFragment"
    android:name="com.commonsware.todo.DisplayFragment"
    android:label="@string/app_name" >
    <argument
      android:name="modelId"
      app:argType="string" />
  </fragment>
</navigation>

RosterRowHolder should look like:

package com.commonsware.todo

import androidx.recyclerview.widget.RecyclerView
import com.commonsware.todo.databinding.TodoRowBinding

class RosterRowHolder(
  private val binding: TodoRowBinding,
  val onCheckboxToggle: (ToDoModel) -> Unit,
  val onRowClick: (ToDoModel) -> Unit
) :
  RecyclerView.ViewHolder(binding.root) {

  fun bind(model: ToDoModel) {
    binding.apply {
      root.setOnClickListener { onRowClick(model) }
      isCompleted.isChecked = model.isCompleted
      isCompleted.setOnCheckedChangeListener { _, _ -> onCheckboxToggle(model) }
      desc.text = model.description
    }
  }
}

RosterAdapter should resemble:

package com.commonsware.todo

import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import com.commonsware.todo.databinding.TodoRowBinding

class RosterAdapter(
  private val inflater: LayoutInflater,
  private val onCheckboxToggle: (ToDoModel) -> Unit,
  private val onRowClick: (ToDoModel) -> Unit
) :
  ListAdapter<ToDoModel, RosterRowHolder>(DiffCallback) {
  override fun onCreateViewHolder(parent: ViewGroup, viewType: Int) =
    RosterRowHolder(
      TodoRowBinding.inflate(inflater, parent, false),
      onCheckboxToggle,
      onRowClick
    )

  override fun onBindViewHolder(holder: RosterRowHolder, position: Int) {
    holder.bind(getItem(position))
  }
}

private object DiffCallback : DiffUtil.ItemCallback<ToDoModel>() {
  override fun areItemsTheSame(oldItem: ToDoModel, newItem: ToDoModel) =
    oldItem.id == newItem.id

  override fun areContentsTheSame(oldItem: ToDoModel, newItem: ToDoModel) =
    oldItem.isCompleted == newItem.isCompleted &&
        oldItem.description == newItem.description
}

RosterListFragment should look like:

package com.commonsware.todo

import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.commonsware.todo.databinding.TodoRosterBinding
import org.koin.androidx.viewmodel.ext.android.viewModel
import androidx.navigation.fragment.findNavController

class RosterListFragment : Fragment() {
  private val motor: RosterMotor by viewModel()
  private var binding: TodoRosterBinding? = null

  override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
  ): View = TodoRosterBinding.inflate(inflater, container, false)
    .also { binding = it }
    .root

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    super.onViewCreated(view, savedInstanceState)

    val adapter = RosterAdapter(
      layoutInflater,
      onCheckboxToggle = { motor.save(it.copy(isCompleted = !it.isCompleted)) },
      onRowClick = ::display)

    binding?.items?.apply {
      setAdapter(adapter)
      layoutManager = LinearLayoutManager(context)

      addItemDecoration(
        DividerItemDecoration(
          activity,
          DividerItemDecoration.VERTICAL
        )
      )
    }

    adapter.submitList(motor.items)
    binding?.empty?.visibility = View.GONE
  }

  override fun onDestroyView() {
    binding = null

    super.onDestroyView()
  }

  private fun display(model: ToDoModel) {
    findNavController()
      .navigate(RosterListFragmentDirections.displayModel(model.id))
  }
}

MainActivity should contain:

package com.commonsware.todo

import android.content.Intent
import android.os.Bundle
import android.view.Menu
import android.view.MenuItem
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.findNavController
import androidx.navigation.fragment.findNavController
import androidx.navigation.ui.AppBarConfiguration
import androidx.navigation.ui.NavigationUI.navigateUp
import androidx.navigation.ui.setupActionBarWithNavController
import com.commonsware.todo.databinding.ActivityMainBinding

class MainActivity : AppCompatActivity() {
  private lateinit var appBarConfiguration: AppBarConfiguration

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    val binding = ActivityMainBinding.inflate(layoutInflater)

    setContentView(binding.root)
    setSupportActionBar(binding.toolbar)

    supportFragmentManager.findFragmentById(R.id.nav_host)?.findNavController()?.let { nav ->
      appBarConfiguration = AppBarConfiguration(nav.graph)
      setupActionBarWithNavController(nav, appBarConfiguration)
    }
  }

  override fun onCreateOptionsMenu(menu: Menu): Boolean {
    menuInflater.inflate(R.menu.actions, menu)

    return super.onCreateOptionsMenu(menu)
  }

  override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
    R.id.about -> {
      startActivity(Intent(this, AboutActivity::class.java))
      true
    }
    else -> super.onOptionsItemSelected(item)
  }

  override fun onSupportNavigateUp() =
    navigateUp(findNavController(R.id.nav_host), appBarConfiguration)
}

The todo_display layout resource should contain:

<?xml version="1.0" encoding="utf-8"?>
<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">

  <ImageView
    android:id="@+id/completed"
    android:layout_width="@dimen/checked_icon_size"
    android:layout_height="@dimen/checked_icon_size"
    android:layout_marginTop="8dp"
    android:layout_marginEnd="8dp"
    android:contentDescription="@string/is_completed"
    app:tint="@color/colorAccent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintTop_toTopOf="parent"
    app:srcCompat="@drawable/ic_check_circle" />

  <TextView
    android:id="@+id/desc"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:layout_marginStart="8dp"
    android:layout_marginTop="8dp"
    android:layout_marginEnd="8dp"
    android:textAppearance="?attr/textAppearanceHeadline1"
    app:layout_constraintEnd_toStartOf="@+id/completed"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toTopOf="parent" />

  <TextView
    android:id="@+id/labelCreated"
    android:layout_width="wrap_content"
    android:layout_height="wrap_content"
    android:layout_marginStart="8dp"
    android:layout_marginTop="8dp"
    android:text="@string/created_on"
    android:textAppearance="?attr/textAppearanceHeadline2"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@+id/desc" />

  <TextView
    android:id="@+id/createdOn"
    android:layout_width="0dp"
    android:layout_height="wrap_content"
    android:layout_marginStart="8dp"
    android:layout_marginTop="8dp"
    android:layout_marginEnd="8dp"
    android:textAppearance="?attr/textAppearanceHeadline2"
    app:layout_constraintEnd_toStartOf="@+id/completed"
    app:layout_constraintStart_toEndOf="@+id/labelCreated"
    app:layout_constraintTop_toBottomOf="@+id/desc" />

  <TextView
    android:id="@+id/notes"
    android:layout_width="0dp"
    android:layout_height="0dp"
    android:layout_marginStart="8dp"
    android:layout_marginTop="8dp"
    android:layout_marginEnd="8dp"
    android:layout_marginBottom="8dp"
    android:textAppearance="?attr/textAppearanceBody1"
    app:layout_constraintBottom_toBottomOf="parent"
    app:layout_constraintEnd_toEndOf="parent"
    app:layout_constraintStart_toStartOf="parent"
    app:layout_constraintTop_toBottomOf="@+id/createdOn" />
</androidx.constraintlayout.widget.ConstraintLayout>

The styles resource should resemble:

<resources>

  <!-- Base application theme. -->
  <style name="Theme.ToDo" parent="Theme.AppCompat.NoActionBar">
    <!-- Customize your theme here. -->
    <item name="colorPrimary">@color/colorPrimary</item>
    <item name="colorPrimaryDark">@color/colorPrimaryDark</item>
    <item name="colorAccent">@color/colorAccent</item>
    <item name="textAppearanceHeadline1">@style/HeadlineOneAppearance</item>
    <item name="textAppearanceHeadline2">@style/HeadlineTwoAppearance</item>
    <item name="textAppearanceBody1">@style/BodyAppearance</item>
  </style>

  <style name="HeadlineOneAppearance" parent="@style/TextAppearance.AppCompat.Large">
    <item name="android:textStyle">bold</item>
  </style>

  <style name="HeadlineTwoAppearance" parent="@style/TextAppearance.AppCompat.Medium">
  </style>

  <style name="BodyAppearance" parent="@style/TextAppearance.AppCompat.Medium">
  </style>

</resources>

SingleModelMotor should contain:

package com.commonsware.todo

import androidx.lifecycle.ViewModel

class SingleModelMotor(
  private val repo: ToDoRepository,
  private val modelId: String
) : ViewModel() {
  fun getModel() = repo.find(modelId)
}

ToDoRepository should contain:

package com.commonsware.todo

class ToDoRepository {
  var items = listOf(
    ToDoModel(
      description = "Buy a copy of _Exploring Android_",
      isCompleted = true,
      notes = "See https://wares.commonsware.com"
    ),
    ToDoModel(
      description = "Complete all of the tutorials"
    ),
    ToDoModel(
      description = "Write an app for somebody in my community",
      notes = "Talk to some people at non-profit organizations to see what they need!"
    )
  )

  fun save(model: ToDoModel) {
    items = if (items.any { it.id == model.id }) {
      items.map { if (it.id == model.id) model else it }
    } else {
      items + model
    }
  }

  fun find(modelId: String) = items.find { it.id == modelId }
}

ToDoApp should contain:

package com.commonsware.todo

import android.app.Application
import org.koin.android.ext.koin.androidLogger
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.core.context.startKoin
import org.koin.dsl.module

class ToDoApp : Application() {
  private val koinModule = module {
    single { ToDoRepository() }
    viewModel { RosterMotor(get()) }
    viewModel { (modelId: String) -> SingleModelMotor(get(), modelId) }
  }

  override fun onCreate() {
    super.onCreate()

    startKoin {
      androidLogger()
      modules(koinModule)
    }
  }
}

Finally, DisplayFragment should now resemble:

package com.commonsware.todo

import android.os.Bundle
import android.text.format.DateUtils
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.navigation.fragment.navArgs
import com.commonsware.todo.databinding.TodoDisplayBinding
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf

class DisplayFragment : Fragment() {
  private val args: DisplayFragmentArgs by navArgs()
  private var binding: TodoDisplayBinding? = null
  private val motor: SingleModelMotor by viewModel { parametersOf(args.modelId) }

  override fun onCreateView(
    inflater: LayoutInflater,
    container: ViewGroup?,
    savedInstanceState: Bundle?
  ) = TodoDisplayBinding.inflate(inflater, container, false)
    .apply { binding = this }
    .root

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    motor.getModel()?.let {
      binding?.apply {
        completed.visibility = if (it.isCompleted) View.VISIBLE else View.GONE
        desc.text = it.description
        createdOn.text = DateUtils.getRelativeDateTimeString(
          requireContext(),
          it.createdOn.toEpochMilli(),
          DateUtils.MINUTE_IN_MILLIS,
          DateUtils.WEEK_IN_MILLIS,
          0
        )
        notes.text = it.notes
      }
    }
  }

  override fun onDestroyView() {
    binding = null

    super.onDestroyView()
  }
}

Prev Table of Contents Next

This book is licensed under the Creative Commons Attribution-ShareAlike 4.0 International license.