Final Results

Our module’s build.gradle file should resemble:

plugins {
  id 'com.android.application'
  id 'kotlin-android'
  id 'androidx.navigation.safeargs.kotlin'
  id 'kotlin-kapt'
}

android {
  compileSdk 31

  defaultConfig {
    applicationId "com.commonsware.todo"
    minSdk 21
    targetSdk 31
    versionCode 1
    versionName "1.0"

    testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
  }

  buildTypes {
    release {
      minifyEnabled false
      proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
    }
  }

  buildFeatures {
    viewBinding true
  }

  compileOptions {
    coreLibraryDesugaringEnabled true
    sourceCompatibility JavaVersion.VERSION_1_8
    targetCompatibility JavaVersion.VERSION_1_8
  }

  kotlinOptions {
    jvmTarget = '1.8'
  }
}

dependencies {
  implementation 'androidx.core:core-ktx:1.6.0'
  implementation 'androidx.appcompat:appcompat:1.3.1'
  implementation 'androidx.constraintlayout:constraintlayout:2.1.0'
  implementation "androidx.recyclerview:recyclerview:1.2.1"
  implementation "androidx.navigation:navigation-fragment-ktx:$nav_version"
  implementation "androidx.navigation:navigation-ui-ktx:$nav_version"
  implementation 'com.google.android.material:material:1.4.0'
  implementation "io.insert-koin:koin-android:$koin_version"
  implementation "androidx.room:room-runtime:$room_version"
  implementation "androidx.room:room-ktx:$room_version"
  kapt "androidx.room:room-compiler:$room_version"
  coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.1.5'
  testImplementation 'junit:junit:4.13.2'
  androidTestImplementation 'androidx.test.ext:junit:1.1.3'
  androidTestImplementation 'androidx.test.espresso:espresso-core:3.4.0'
}

In the end, RosterMotor should contain:

package com.commonsware.todo.ui.roster

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.commonsware.todo.repo.ToDoModel
import com.commonsware.todo.repo.ToDoRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

data class RosterViewState(
  val items: List<ToDoModel> = listOf()
)

class RosterMotor(private val repo: ToDoRepository) : ViewModel() {
  val states = repo.items()
    .map { RosterViewState(it) }
    .stateIn(viewModelScope, SharingStarted.Eagerly, RosterViewState())

  fun save(model: ToDoModel) {
    viewModelScope.launch {
      repo.save(model)
    }
  }
}

The updated RosterListFragment should look like:

package com.commonsware.todo.ui.roster

import android.os.Bundle
import android.view.*
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.commonsware.todo.R
import com.commonsware.todo.databinding.TodoRosterBinding
import com.commonsware.todo.repo.ToDoModel
import kotlinx.coroutines.flow.collect
import org.koin.androidx.viewmodel.ext.android.viewModel

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

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

    setHasOptionsMenu(true)
  }

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

    viewLifecycleOwner.lifecycleScope.launchWhenStarted {
      motor.states.collect { state ->
        adapter.submitList(state.items)

        binding?.apply {
          when {
            state.items.isEmpty() -> {
              empty.visibility = View.VISIBLE
              empty.setText(R.string.msg_empty)
            }
            else -> empty.visibility = View.GONE
          }
        }
      }
    }

    binding?.empty?.visibility = View.GONE
  }

  override fun onDestroyView() {
    binding = null

    super.onDestroyView()
  }

  override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
    inflater.inflate(R.menu.actions_roster, menu)

    super.onCreateOptionsMenu(menu, inflater)
  }

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

    return super.onOptionsItemSelected(item)
  }

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

  private fun add() {
    findNavController().navigate(RosterListFragmentDirections.createModel(null))
  }
}

Our revised SingleModelMotor should contain:

package com.commonsware.todo.ui

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.commonsware.todo.repo.ToDoModel
import com.commonsware.todo.repo.ToDoRepository
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch

data class SingleModelViewState(
  val item: ToDoModel? = null
)

class SingleModelMotor(
  private val repo: ToDoRepository,
  modelId: String?
) : ViewModel() {
  val states = repo.find(modelId)
    .map { SingleModelViewState(it) }
    .stateIn(viewModelScope, SharingStarted.Eagerly, SingleModelViewState())

  fun save(model: ToDoModel) {
    viewModelScope.launch {
      repo.save(model)
    }
  }

  fun delete(model: ToDoModel) {
    viewModelScope.launch {
      repo.delete(model)
    }
  }
}

The tweaked DisplayFragment should resemble:

package com.commonsware.todo.ui.display

import android.os.Bundle
import android.text.format.DateUtils
import android.view.*
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.commonsware.todo.R
import com.commonsware.todo.databinding.TodoDisplayBinding
import com.commonsware.todo.ui.SingleModelMotor
import kotlinx.coroutines.flow.collect
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 onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)

    setHasOptionsMenu(true)
  }

  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?) {
    viewLifecycleOwner.lifecycleScope.launchWhenStarted {
      motor.states.collect { state ->
        state.item?.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()
  }

  override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
    inflater.inflate(R.menu.actions_display, menu)

    super.onCreateOptionsMenu(menu, inflater)
  }

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

    return super.onOptionsItemSelected(item)
  }

  private fun edit() {
    findNavController().navigate(
      DisplayFragmentDirections.editModel(
        args.modelId
      )
    )
  }
}

And the current EditFragment should look like:

package com.commonsware.todo.ui.edit

import android.os.Bundle
import android.view.*
import android.view.inputmethod.InputMethodManager
import androidx.core.content.getSystemService
import androidx.fragment.app.Fragment
import androidx.lifecycle.lifecycleScope
import androidx.navigation.fragment.findNavController
import androidx.navigation.fragment.navArgs
import com.commonsware.todo.R
import com.commonsware.todo.databinding.TodoEditBinding
import com.commonsware.todo.repo.ToDoModel
import com.commonsware.todo.ui.SingleModelMotor
import kotlinx.coroutines.flow.collect
import org.koin.androidx.viewmodel.ext.android.viewModel
import org.koin.core.parameter.parametersOf

class EditFragment : Fragment() {
  private var binding: TodoEditBinding? = null
  private val args: EditFragmentArgs by navArgs()
  private val motor: SingleModelMotor by viewModel { parametersOf(args.modelId) }

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

    setHasOptionsMenu(true)
  }

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

  override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
    viewLifecycleOwner.lifecycleScope.launchWhenStarted {
      motor.states.collect { state ->
        if (savedInstanceState == null) {
          state.item?.let {
            binding?.apply {
              isCompleted.isChecked = it.isCompleted
              desc.setText(it.description)
              notes.setText(it.notes)
            }
          }
        }
      }
    }
  }

  override fun onDestroyView() {
    binding = null

    super.onDestroyView()
  }

  override fun onCreateOptionsMenu(menu: Menu, inflater: MenuInflater) {
    inflater.inflate(R.menu.actions_edit, menu)
    menu.findItem(R.id.delete).isVisible = args.modelId != null

    super.onCreateOptionsMenu(menu, inflater)
  }

  override fun onOptionsItemSelected(item: MenuItem): Boolean {
    when (item.itemId) {
      R.id.save -> {
        save()
        return true
      }
      R.id.delete -> {
        delete()
        return true
      }
    }

    return super.onOptionsItemSelected(item)
  }

  private fun save() {
    binding?.apply {
      val model = motor.states.value.item
      val edited = model?.copy(
        description = desc.text.toString(),
        isCompleted = isCompleted.isChecked,
        notes = notes.text.toString()
      ) ?: ToDoModel(
        description = desc.text.toString(),
        isCompleted = isCompleted.isChecked,
        notes = notes.text.toString()
      )

      edited.let { motor.save(it) }
    }

    navToDisplay()
  }

  private fun delete() {
    val model = motor.states.value.item

    model?.let { motor.delete(it) }
    navToList()
  }

  private fun navToDisplay() {
    hideKeyboard()
    findNavController().popBackStack()
  }

  private fun navToList() {
    hideKeyboard()
    findNavController().popBackStack(R.id.rosterListFragment, false)
  }

  private fun hideKeyboard() {
    view?.let {
      val imm = context?.getSystemService<InputMethodManager>()

      imm?.hideSoftInputFromWindow(
        it.windowToken,
        InputMethodManager.HIDE_NOT_ALWAYS
      )
    }
  }
}

Prev Table of Contents Next

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