How to Build a CRUD App With Kotlin and Android Studio

Chapter 18 Menu Item ”Diary entries”

In this chapter we implement the code for listing diary entries. We start by defining our view model:

18.1 DiaryEntriesListViewModel.kt

Listing 119: DiaryEntriesListViewModel.kt
package app.gedama.tutorial.cruddiary.viewmodel
import androidx.lifecycle.*
import app.gedama.tutorial.cruddiary.GlobalAppStore
import app.gedama.tutorial.cruddiary.dao.DiaryEntryDao
import app.gedama.tutorial.cruddiary.data.DiaryEntryWithEverything
class DiaryEntriesListViewModel(diaryEntryDao: DiaryEntryDao)  : BaseViewModel()  {
    private val showDeactivated = GlobalAppStore._showInactive
    var diaryEntries: LiveData<List<DiaryEntryWithEverything>>
    init {
        diaryEntries = showDeactivated.switchMap { showDeactivated ->
            diaryEntryDao.getDiaryEntriesWithEverything(0L, showDeactivated)
        }
    }
}
class DiaryEntriesListViewModelFactory(private val diaryEntryDao: DiaryEntryDao) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(DiaryEntriesListViewModel::class.java)) {
            return DiaryEntriesListViewModel(diaryEntryDao) as T
        }
        throw java.lang.IllegalArgumentException("Unknown ViewModel")
    }
}

This code should look familiar to you: we define the LiveData variable diaryEntries, which returns all diary entries contained in the database. The value 0L is passed to the getDiaryEntriesWithEverything() method because with diary entries there is no dummy value ’—’ as with our other data classes. And depending on the variable showDeactivated, deactivated diary entries are displayed or not.

And as with our previous view model, the ViewModelFactory DiaryEntriesListViewModelFactory is defined in the same file, which gets our DiaryEntryDao class as a parameter, so that we can access the database in the ViewModel.

Next, we define the layout file for use in our RecyclerView:

18.2 diary_entry_item.xml

Listing 120: diary_entry_item.xml
<?xml version="1.0" encoding="utf-8"?>
<layout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:tools="http://schemas.android.com/tools">
    <data>
        <variable
            name="diaryEntryWithEverything"
            type="app.gedama.tutorial.cruddiary.data.DiaryEntryWithEverything" />
    </data>
    <androidx.cardview.widget.CardView
        android:id="@+id/cardView"
        style="@style/CrudDiary_LinearLayoutCompat_CardView" >
        <GridLayout
            style="@style/CrudDiary_LinearLayoutCompat_CardView_Grid"
            android:rowCount="3">
            <TextView
                android:id="@+id/entry_title"
                style="@style/CrudDiary_LinearLayoutCompat_CardView_Title"
                tools:text="Radelsdrüberunterberger Hummenthaler, Susanne"
                android:text="@{diaryEntryWithEverything.entry.title}"/>
            <ImageView
                android:id="@+id/image"
                style="@style/CrudDiary_LinearLayoutCompat_CardView_Image"
                android:layout_rowSpan="3"
                android:contentDescription="@string/item_image_description"
                tools:src="@tools:sample/avatars" />
            <TextView
                android:id="@+id/person_name"
                style="@style/CrudDiary_LinearLayoutCompat_CardView_Text"
                android:layout_row="1"
                tools:text="Radelsdrüberunterberger Hummenthaler, Susanne"
                android:text="@{diaryEntryWithEverything.person.name}"/>
            <TextView
                android:id="@+id/diary_entry_date"
                style="@style/CrudDiary_LinearLayoutCompat_CardView_Text"
                android:layout_row="2"
                tools:text="21.10.1991"
                android:text="@{diaryEntryWithEverything.entry.dateAsString}"/>
        </GridLayout>
    </androidx.cardview.widget.CardView>
</layout>

The layout file diary_entry_item.xml defines how our diary entry elements from the database are displayed in the RecyclerView. For this purpose, we use a GridLayout within a CardView: we display the entry’s title, the person’s name, the diary entry’s date on the left side and an (optional) diary entry image on the right side.

The data binding variable diaryEntryWithEverything is defined by means of the data-element, which we access a little further down in the code for our DiaryEntryItemAdapter. Next we define the layout for our fragment:

18.3 fragment_diary_entries_list.xml

Listing 121: fragment_diary_entries_list.xml
<?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"
    xmlns:tools="http://schemas.android.com/tools"
    tools:context="app.gedama.tutorial.cruddiary.fragment.DiaryEntriesListFragment">
    <data>
        <variable
            name="viewModel"
            type="app.gedama.tutorial.cruddiary.viewmodel.DiaryEntriesListViewModel" />
    </data>
    <LinearLayout
        android:layout_width="match_parent"
        android:layout_height="match_parent"
        android:orientation="vertical">
        <TextView
            android:id="@id/no_entries_text_id"
            style="@style/CrudDiary_NoEntries"
            tools:visibility="visible"
            android:text="@string/no_entries" />
        <androidx.recyclerview.widget.RecyclerView
            android:id="@+id/diary_entries_list"
            android:layout_width="match_parent"
            android:layout_height="0dp"
            android:layout_weight="1"
            android:gravity="top"
            app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
    </LinearLayout>
</layout>

In fragment_diary_entries_list.xml, a RecyclerView is used to display our diary entries. As with other lists, we also define a text view for displaying an empty list info in case there have no diary entries been stored in the database yet. By means of the data-element, the variable viewModel is defined, which we access in the code in our fragment FragmentDiaryEntriesList.kt, which we will deal a bit later.

Now let’s define our adapter, which implements the Filterable interface, since we want to search items in the recycler view:

18.4 DiaryEntryItemAdapter.kt

Listing 122: DiaryEntryItemAdapter.kt
package app.gedama.tutorial.cruddiary.adapter
import android.util.TypedValue
import android.view.LayoutInflater
import android.view.ViewGroup
import android.widget.Filter
import android.widget.Filterable
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import app.gedama.tutorial.cruddiary.R
import app.gedama.tutorial.cruddiary.data.DiaryEntryWithEverything
import app.gedama.tutorial.cruddiary.databinding.DiaryEntryItemBinding
import com.squareup.picasso.Picasso
import java.io.File
class DiaryEntryItemAdapter(val clickListener: (diaryEntry: DiaryEntryWithEverything) -> Unit): ListAdapter<DiaryEntryWithEverything, DiaryEntryItemAdapter.DiaryEntryItemViewHolder>(DiaryEntryDiffItemCallback()),
    Filterable {
    private var list = mutableListOf<DiaryEntryWithEverything>()
    var _adapterSize = MutableLiveData(1)
    val adapterSize: LiveData<Int>
        get() = _adapterSize
    override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): DiaryEntryItemViewHolder =
        DiaryEntryItemViewHolder.inflateFrom(parent)
    override fun onBindViewHolder(holder: DiaryEntryItemViewHolder, position: Int) {
        val item = getItem(position)
        holder.bind(item, clickListener)
    }
    override fun onCurrentListChanged(
        previousList: MutableList<DiaryEntryWithEverything>,
        currentList: MutableList<DiaryEntryWithEverything>
    ) {
        super.onCurrentListChanged(previousList, currentList)
        _adapterSize.value = currentList.size
    }
    // see https://stackoverflow.com/questions/65950521/how-to-use-filterable-in-listadapter-using-kotlin
    fun setData(list: MutableList<DiaryEntryWithEverything>?){
        this.list = list!!
        submitList(list)
    }
    override fun getFilter(): Filter {
        return customFilter
    }
    private val customFilter = object : Filter() {
        override fun performFiltering(constraint: CharSequence?): FilterResults {
            val filteredList = mutableListOf<DiaryEntryWithEverything>()
            if (constraint == null || constraint.isEmpty()) {
                filteredList.addAll(list)
            } else {
                for (item in list) {
                    if (item.entry.title.lowercase().contains(constraint.toString().lowercase())
                        || item.entry.dateAsString.lowercase().contains(constraint.toString().lowercase())
                        || item.person.name.lowercase().contains(constraint.toString().lowercase())
                        || item.tags.joinToString { it.title }.lowercase().contains(constraint.toString().lowercase())
                        || item.entry.text.lowercase().contains(constraint.toString().lowercase())) {
                        filteredList.add(item)
                    }
                }
            }
            val results = FilterResults()
            results.values = filteredList
            return results
        }
        override fun publishResults(constraint: CharSequence?, filterResults: FilterResults?) {
            submitList(filterResults?.values as MutableList<DiaryEntryWithEverything>)
        }
    }
    class DiaryEntryItemViewHolder(val binding: DiaryEntryItemBinding) :RecyclerView.ViewHolder(binding.root) {
        companion object {
            fun inflateFrom(parent: ViewGroup) : DiaryEntryItemViewHolder {
                val layoutInflater = LayoutInflater.from(parent.context)
                val binding = DiaryEntryItemBinding.inflate(layoutInflater, parent, false)
                return DiaryEntryItemViewHolder(binding)
            }
        }
        fun bind(item: DiaryEntryWithEverything, clickListener: (diaryEntry: DiaryEntryWithEverything) -> Unit) {
            binding.diaryEntryWithEverything = item
            binding.root.setOnClickListener {
                clickListener(item)
            }
            if (item.entry.imagePath.isNotBlank()) {
                val img = Picasso.get().load(File(binding.root.context.filesDir, item.entry.imagePath))
                img?.into(binding.image)
            } else {
                binding.image.setImageResource(R.drawable.baseline_edit_note_24)
            }
            if (item.entry.inactive) {
                val typedValue = TypedValue()
                binding.root.context.theme.resolveAttribute(R.attr.inactive_background_color, typedValue, true)
                binding.cardView.setCardBackgroundColor(typedValue.data)
            } else {
                val typedValue = TypedValue()
                binding.root.context.theme.resolveAttribute(
                    R.attr.active_background_color, typedValue, true)
                binding.cardView.setCardBackgroundColor(typedValue.data)
            }
        }
    }
}
class DiaryEntryDiffItemCallback : DiffUtil.ItemCallback<DiaryEntryWithEverything>() {
    override fun areItemsTheSame(oldItem: DiaryEntryWithEverything, newItem: DiaryEntryWithEverything) = (oldItem.entry.entryId == newItem.entry.entryId)
    override fun areContentsTheSame(oldItem: DiaryEntryWithEverything, newItem: DiaryEntryWithEverything) = (oldItem == newItem)
}

There’s not much you haven’t seen in previous chapters. When searching for diary entries in the performFiltering() method, we do this within a couple of attributes: the diary entry’s title, the date, the name of the person, all the assigned tags, and also within the diary entry’s actual text. Notice in the bind method that when displaying the diary entry’s image we check if the user has provided a picture - if not, we display a default icon from our drawables-folder (baseline_edit_note_24).

Next, we look at our diary entries list fragment:

18.5 FragmentDiaryEntriesList.kt

Listing 123: FragmentDiaryEntriesList.kt
package app.gedama.tutorial.cruddiary.fragment
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.ViewModelProvider
import app.gedama.tutorial.cruddiary.GlobalAppStore
import app.gedama.tutorial.cruddiary.adapter.DiaryEntryItemAdapter
import app.gedama.tutorial.cruddiary.database.CrudDiaryDatabase
import app.gedama.tutorial.cruddiary.databinding.FragmentDiaryEntriesListBinding
import app.gedama.tutorial.cruddiary.viewmodel.DiaryEntriesListViewModel
import app.gedama.tutorial.cruddiary.viewmodel.DiaryEntriesListViewModelFactory
class DiaryEntriesListFragment : BaseFragment() {
    private var _binding: FragmentDiaryEntriesListBinding? = null
    private val binding get() = _binding!!
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentDiaryEntriesListBinding.inflate(inflater, container, false)
        val view = binding.root
        val application = requireNotNull(this.activity).application
        val diaryEntryDao = CrudDiaryDatabase.getInstance(application).diaryEntryDao
        val viewModelFactory = DiaryEntriesListViewModelFactory(diaryEntryDao)
        val viewModel = ViewModelProvider(this, viewModelFactory)[DiaryEntriesListViewModel::class.java]
        binding.viewModel = viewModel
        binding.lifecycleOwner = viewLifecycleOwner
        val adapter = DiaryEntryItemAdapter() { diaryEntry ->
            viewModel.onItemClicked(diaryEntry.entry.entryId)
        }
        binding.diaryEntriesList.adapter = adapter
        // set adapter for search view in toolbar
        GlobalAppStore.someFilterableAdapter = adapter
        activateAddFAB({ viewModel.addItem() })
        viewModel.diaryEntries.observe(viewLifecycleOwner, {
            it?.let {
                adapter.setData(it.toMutableList())
                if ( it.size == 0 ) {
                    binding.noEntriesTextId.visibility = View.VISIBLE
                    binding.diaryEntriesList.visibility = View.GONE
                } else {
                    binding.noEntriesTextId.visibility = View.GONE
                    binding.diaryEntriesList.visibility = View.VISIBLE
                }
            }
        })
        adapter.adapterSize.observe( viewLifecycleOwner, { it ->
            if ( it == 0 ) {
                binding.noEntriesTextId.visibility = View.VISIBLE
                binding.diaryEntriesList.visibility = View.GONE
            } else {
                binding.noEntriesTextId.visibility = View.GONE
                binding.diaryEntriesList.visibility = View.VISIBLE
            }
        })
        return view
    }
    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
        deactivateAddFAB()
        // remove search view in toolbar
        this.requireActivity().invalidateOptionsMenu()
    }
}

This code is similar to the one for displaying items in previous chapters. As one of the last steps we have to extend our navigation file nav_graph.xml with our new fragment and define actions for the navigation transitions from the home fragment and back (either in the graphical editor or directly in the XML code):

18.6 nav_graph.xml

Listing 124: nav_graph.xml
<?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"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/nav_graph"
    app:startDestination="@id/homeFragment">
    <fragment
        android:id="@+id/homeFragment"
        android:name="app.gedama.tutorial.cruddiary.fragment.HomeFragment"
        android:label="@string/main_menu_title" >
        <action
            android:id="@+id/action_homeFragment_to_gendersListFragment"
            app:destination="@id/gendersListFragment" />
        <action
            android:id="@+id/action_homeFragment_to_personsListFragment"
            app:destination="@id/personsListFragment" />
        <action
            android:id="@+id/action_homeFragment_to_tagsListFragment"
            app:destination="@id/tagsListFragment" />
        <action
            android:id="@+id/action_homeFragment_to_diaryEntriesListFragment"
            app:destination="@id/diaryEntriesListFragment" />
    </fragment>
    <fragment
        android:id="@+id/gendersListFragment"
        android:name="app.gedama.tutorial.cruddiary.fragment.GendersListFragment"
        android:label="@string/genders_list_menu_title"
        tools:layout="@layout/fragment_genders_list" >
        <action
            android:id="@+id/action_gendersListFragment_to_homeFragment"
            app:destination="@id/homeFragment" />
        <action
            android:id="@+id/action_gendersListFragment_to_addGenderFragment"
            app:destination="@id/addGenderFragment" />
        <action
            android:id="@+id/action_gendersListFragment_to_updateGenderFragment"
            app:destination="@id/updateGenderFragment" />
    </fragment>
    <fragment
        android:id="@+id/addGenderFragment"
        android:name="app.gedama.tutorial.cruddiary.fragment.AddGenderFragment"
        android:label="@string/genders_add_menu_title" >
        <action
            android:id="@+id/action_addGenderFragment_to_gendersListFragment"
            app:destination="@id/gendersListFragment"
            app:popUpTo="@id/homeFragment" />
    </fragment>
    <fragment
        android:id="@+id/updateGenderFragment"
        android:name="app.gedama.tutorial.cruddiary.fragment.UpdateGenderFragment"
        android:label="@string/genders_update_menu_title" >
        <action
            android:id="@+id/action_updateGenderFragment_to_gendersListFragment"
            app:destination="@id/gendersListFragment"
            app:popUpTo="@id/homeFragment" />
        <argument
            android:name="genderId"
            app:argType="long" />
    </fragment>
    <fragment
        android:id="@+id/personsListFragment"
        android:name="app.gedama.tutorial.cruddiary.fragment.PersonsListFragment"
        android:label="@string/persons_list_menu_title"
        tools:layout="@layout/fragment_persons_list" >
        <action
            android:id="@+id/action_personsListFragment_to_homeFragment"
            app:destination="@id/homeFragment" />
        <action
            android:id="@+id/action_personsListFragment_to_addPersonFragment"
            app:destination="@id/addPersonFragment" />
        <action
            android:id="@+id/action_personsListFragment_to_updatePersonFragment"
            app:destination="@id/updatePersonFragment" />
    </fragment>
    <fragment
        android:id="@+id/addPersonFragment"
        android:name="app.gedama.tutorial.cruddiary.fragment.AddPersonFragment"
        android:label="@string/persons_add_menu_title" >
        <action
            android:id="@+id/action_addPersonFragment_to_personsListFragment"
            app:destination="@id/personsListFragment"
            app:popUpTo="@id/homeFragment" />
    </fragment>
    <fragment
        android:id="@+id/updatePersonFragment"
        android:name="app.gedama.tutorial.cruddiary.fragment.UpdatePersonFragment"
        android:label="@string/persons_update_menu_title" >
        <action
            android:id="@+id/action_updatePersonFragment_to_personsListFragment"
            app:destination="@id/personsListFragment"
            app:popUpTo="@id/homeFragment" />
        <argument
            android:name="personId"
            app:argType="long" />
    </fragment>
    <fragment
        android:id="@+id/tagsListFragment"
        android:name="app.gedama.tutorial.cruddiary.fragment.TagsListFragment"
        android:label="@string/tags_list_menu_title" >
        <action
            android:id="@+id/action_tagsListFragment_to_homeFragment"
            app:destination="@id/homeFragment" />
        <action
            android:id="@+id/action_tagsListFragment_to_addTagFragment"
            app:destination="@id/addTagFragment" />
        <action
            android:id="@+id/action_tagsListFragment_to_updateTagFragment"
            app:destination="@id/updateTagFragment" />
    </fragment>
    <fragment
        android:id="@+id/addTagFragment"
        android:name="app.gedama.tutorial.cruddiary.fragment.AddTagFragment"
        android:label="@string/tags_add_menu_title" >
        <action
            android:id="@+id/action_addTagFragment_to_tagsListFragment"
            app:destination="@id/tagsListFragment"
            app:popUpTo="@id/homeFragment" />
    </fragment>
    <fragment
        android:id="@+id/updateTagFragment"
        android:name="app.gedama.tutorial.cruddiary.fragment.UpdateTagFragment"
        android:label="@string/tags_edit_menu_title" >
        <action
            android:id="@+id/action_updateTagFragment_to_tagsListFragment"
            app:destination="@id/tagsListFragment"
            app:popUpTo="@id/homeFragment" />
        <argument
            android:name="tagId"
            app:argType="long" />
    </fragment>
    <fragment
        android:id="@+id/updatePreferencesFragment"
        android:name="app.gedama.tutorial.cruddiary.fragment.UpdatePreferencesFragment"
        android:label="@string/preferences_menu_title" >
        <action
            android:id="@+id/action_updatePreferencesFragment_to_homeFragment"
            app:destination="@id/homeFragment"
            app:popUpTo="@id/homeFragment"
            app:popUpToInclusive="true" />
    </fragment>
    <fragment
        android:id="@+id/diaryEntriesListFragment"
        android:name="app.gedama.tutorial.cruddiary.fragment.DiaryEntriesListFragment"
        android:label="@string/diary_entries_list_menu_title"
        tools:layout="@layout/fragment_diary_entries_list" >
        <action
            android:id="@+id/action_diaryEntriesListFragment_to_homeFragment"
            app:destination="@id/homeFragment" />
    </fragment>
</navigation>

And our final step is to update MainActivity.kt so that we can search within the diary entries list:

18.7 MainActivity.kt

Listing 125: MainActivity.kt
...
import app.gedama.tutorial.cruddiary.fragment.DiaryEntriesListFragment
...
        when (fragment) {
            is PersonsListFragment -> searchMenuItem.isVisible = true
            is TagsListFragment -> searchMenuItem.isVisible = true
            is DiaryEntriesListFragment -> searchMenuItem.isVisible = true
            else -> searchMenuItem.isVisible = false
        }
...

With all these new files and extensions of existing files, the code should now rebuild and run on a (virtual) device. If you tap on the menu item ’Diary entries’ in the main menu, an info is shown that there are no entries in the database yet (see Figure 18.1). At the bottom right you will spot the FAB for adding a new diary entry which we cover in the next chapter.

Empty diary entries list
Figure 18.1: Empty diary entries list