How to Build a CRUD App With Kotlin and Android Studio

Chapter 19 Add Diary Entries

Problems covered in this chapter
  • Speech input

  • Saving images and PDF files

  • Reusable XML blocks for insertions and updates

  • Styles to unify the user interface

  • Basic fragment class with useful methods for all further fragments

  • Form validations in the user interface

  • Generic date-time picker for date values

In the last chapter we covered the listing of diary entries stored in the database. In order to actually see entries in this list, we now enable the user to add new diary entries (see Figure 19.1).

Input screen for adding diary entries
Figure 19.1: Input screen for adding diary entries

Let’s start with our view model:

19.1 AddDiaryEntryViewModel.kt

Listing 126: AddDiaryEntryViewModel.kt
package app.gedama.tutorial.cruddiary.viewmodel
import android.util.Log
import androidx.databinding.ObservableField
import androidx.lifecycle.*
import app.gedama.tutorial.cruddiary.dao.DiaryEntryDao
import app.gedama.tutorial.cruddiary.data.DiaryEntriesTagsCrossRef
import app.gedama.tutorial.cruddiary.data.DiaryEntry
import app.gedama.tutorial.cruddiary.data.Spinnable
import app.gedama.tutorial.cruddiary.data.Tag
import kotlinx.coroutines.launch
import java.util.*
class AddDiaryEntryViewModel(private val diaryEntryDao: DiaryEntryDao)  : BaseViewModel()  {
    var newDiaryEntry = DiaryEntry()
    val allPersons = diaryEntryDao.getAllPersons(0L)
    var allTags = diaryEntryDao.getAllTags(1L)
    var relevance = newDiaryEntry.relevance
    var relevanceDataBindingVariable =
        object: ObservableField<String>(relevance.toString()) {
            override fun set(value: String?) {
                super.set(value)
                // a value has been set
                relevance = value?.toIntOrNull() ?: relevance
            }
        }
    private val _selectedTags = MutableLiveData<Set<Tag>>(mutableSetOf())
    val selectedTags: LiveData<Set<Tag>>
        get() = _selectedTags
    private val _selectableTags = MutableLiveData<Set<Tag>>(mutableSetOf())
    val selectableTags: LiveData<Set<Tag>>
        get() = _selectableTags
    private val _newEntryDate = MutableLiveData<Date>()
    val newEntryDate: LiveData<Date>
        get() = _newEntryDate
    fun addDiaryEntry() {
        _doValidation.value = true
    }
    override fun updateEntryAfterValidation() {
        viewModelScope.launch {
            newDiaryEntry.relevance = relevanceDataBindingVariable.get()?.toIntOrNull() ?: 3
            if ( imageBitmap != null ) {
                newDiaryEntry.imagePath = writeImage("diary_entry_")
            }
            val insertedEntryId = diaryEntryDao.insert(newDiaryEntry)
            val selTagList = selectedTags.value
            if ( selTagList != null && selTagList.isNotEmpty() ) {
                selTagList.forEach {
                    val diaryEntryPair = DiaryEntriesTagsCrossRef(insertedEntryId,it.tagId)
                    diaryEntryDao.insertDiaryEntryTag(diaryEntryPair)
                }
            } else {
                // if no tag is assigned, insert dummy tag (---) so that filtering works
                val entryTagPair = DiaryEntriesTagsCrossRef(insertedEntryId,1L)
                diaryEntryDao.insertDiaryEntryTag(entryTagPair)
            }
            _navigateToList.value = true
        }
    }
    fun updateDiaryPerson(item: Spinnable) {
        newDiaryEntry.personId = item.itemId()
    }
    override fun updateEntryDate(date: Date) {
        _newEntryDate.value = date
        newDiaryEntry.date = date
    }
    fun setSelectedTag(tag: Tag) {
        _selectedTags.postValue(selectedTags.value!!.plus(tag))
        _selectableTags.postValue(selectableTags.value!!.minus(tag))
        Log.d(TAG,"Tag ${tag.title} was selected ...")
        Log.d(TAG,"Selected tags: ${selectedTags.value}")
    }
    fun deSelectTag(tag: Tag) {
        _selectedTags.postValue(selectedTags.value!!.minus(tag))
        _selectableTags.postValue(selectableTags.value!!.plus(tag))
        Log.d(TAG,"Tag ${tag.title} was deselected ...")
        Log.d(TAG,"Selected tags: ${selectedTags.value}")
    }
    fun setSelectableTags(set: Set<Tag>) {
        _selectableTags.postValue(set)
    }
}
class AddDiaryEntryViewModelFactory(private val diaryEntryDao: DiaryEntryDao) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(AddDiaryEntryViewModel::class.java)) {
            @Suppress("UNCHECKED_CAST")
            return AddDiaryEntryViewModel(diaryEntryDao) as T
        }
        throw java.lang.IllegalArgumentException("Unknown ViewModel")
    }
}

What’s new to this class are the LiveData variables selectedTags and selectableTags: there we store the tags that are already assigned to the diary entry as well as the tags that are still available for assigning. There are correspongind methods, setSelectedTag() and deSelectTag() as well as setSelectableTags(), where these LiveData variables are modified in relation to the user’s actions. And notice the updateEntryAfterValidation() method where we store the new diary entry in table diary_entries as well as all assigned tags in table diary_entries_tags. If the user hasn’t assigned any tag we insert the dummy tag ’—’: we do this so that filtering (see Chapter XYZ) works in this case. And we define variables relevance and relevanceDataBindingVariable: since binding variables in the layout files cannot handle integer values we have to provide a means for converting them to string values which we then use in the layout files.

Let’s move on to the layout part. First, we add a new vector asset to our drawables folder: baseline_mic_24

Next we extend our string.xml files:

19.2 string.xml (English)

Listing 127: string.xml (English)
<resources>
    <string name="app_name">CrudDiary</string>
    <string name="nothing_selected" translatable="false">---</string>
    <string name="male_gender">male</string>
    <string name="female_gender">female</string>
    <string name="diverse_gender">diverse</string>
    <string name="main_menu_title">Home</string>
    <string name="persons_list_menu_title">Persons</string>
    <string name="persons_add_menu_title">Add person</string>
    <string name="persons_update_menu_title">Update person</string>
    <string name="tags_list_menu_title">Tags</string>
    <string name="tags_add_menu_title">Add tag</string>
    <string name="tags_edit_menu_title">Update tag</string>
    <string name="diary_entries_list_menu_title">Diary entries</string>
    <string name="diary_entries_add_menu_title">Add diary entry</string>
    <string name="diary_entries_edit_menu_title">Update diary entry</string>
    <string name="genders_list_menu_title">Genders</string>
    <string name="genders_add_menu_title">Add gender</string>
    <string name="genders_update_menu_title">Update gender</string>
    <string name="help_menu_title">Help</string>
    <string name="support_section">Support</string>
    <string name="preferences_menu_title">Preferences</string>
    <string name="import_export_menu_title">Import/Export</string>
    <string name="search_view_hint">Search entries</string>
    <string name="fab_add_description">Add entry</string>
    <string name="fab_filter_description">Filter entries</string>
    <string name="fab_csv_description">Export entries to CSV file</string>
    <string name="name_label">Name</string>
    <string name="save_button">Save</string>
    <string name="deactivate_button">Deactivate</string>
    <string name="delete_button">Delete</string>
    <string name="update_button">Update</string>
    <string name="save_preferences_button">Save preferences</string>
    <string name="unique_gender_violation">This gender name already exists!</string>
    <string name="unique_person_violation">This person already exists!</string>
    <string name="unique_tag_violation">This tag already exists!</string>
    <string name="validation_no_name">Name is required</string>
    <string name="validation_no_title">Title is required</string>
    <string name="validation_no_birthdate">Birthdate is required</string>
    <string name="validation_no_person">Person is required</string>
    <string name="validation_no_gender">Gender is required</string>
    <string name="validation_no_text">Text is required</string>
    <string name="inactive_label">Inactive</string>
    <string name="inactivity_safety_dialog_title">Safety check</string>
    <string name="inactivity_safety_check">Actually deactivate entry?</string>
    <string name="yes">Yes</string>
    <string name="no">No</string>
    <string name="no_entries">You either have not created any entries yet or there are no entries matching your search/filter criteria.</string>
    <string name="item_image_description">Assigned image</string>
    <string name="add_image_button">Add image</string>
    <string name="change_image_button">Change image</string>
    <string name="delete_image_button">Delete image</string>
    <string name="image_picking_canceled">Image picking canceled</string>
    <string name="person_gender_label">Gender</string>
    <string name="person_birthdate_label">Birthdate</string>
    <string name="title_label">Title</string>
    <string name="show_inactive_label">List inactive items</string>
    <string name="person_main_user_label">Main user</string>
    <string name="tags_label">Tags</string>
    <string name="diary_title_label">Title</string>
    <string name="add_tag_button">Add tag</string>
    <string name="headline_tags_list">Available tags</string>
    <string name="no_tags_available">No tags available yet.</string>
    <string name="no_further_tags_available">No further tags available.</string>
    <string name="date_label">Date</string>
    <string name="text_label">Entry text</string>
    <string name="person_label">For</string>
    <string name="relevance_label">Relevance</string>
    <string name="possible_digits" translatable="false">12345</string>
    <string name="validation_no_relevance">Relevance value is required</string>
    <string name="validation_relevance_digits">Invalid relevance value </string>
    <string name="validation_relevance_positive_number">Relevance must be between 1 and 5!</string>
</resources>

Next, we extend our themes.xml file with two new drawable-definitions and a new style CrudDiary_Button.AddTag:

19.3 themes.xml (Light-Mode)

Listing 128: themes.xml (Light-Mode)
<resources xmlns:tools="http://schemas.android.com/tools">
    <drawable name="saveItemButtonIcon">@drawable/baseline_save_24</drawable>
    <drawable name="deactivateItemButtonIcon">@drawable/baseline_do_not_disturb_alt_24</drawable>
    <drawable name="deleteItemButtonIcon">@drawable/baseline_delete_24</drawable>
    <drawable name="calendarSelectIcon">@drawable/baseline_calendar_month_24</drawable>
    <drawable name="addImageButtonIcon">@drawable/baseline_add_photo_alternate_24</drawable>
    <drawable name="changeImageButtonIcon">@drawable/baseline_photo_size_select_large_24</drawable>
    <drawable name="deleteImageButtonIcon">@drawable/baseline_hide_image_24</drawable>
    <drawable name="microphoneIcon">@drawable/baseline_mic_24</drawable>
    <drawable name="tagIcon">@drawable/baseline_tag_24</drawable>
    <item type="id" name="no_entries_text_id" />
    <!-- Base application theme. -->
    <style name="Theme.CrudDiary" parent="Theme.MaterialComponents.DayNight.NoActionBar">
        <!-- Primary brand color. -->
        <item name="colorPrimary">@color/purple_500</item>
        <item name="colorPrimaryVariant">@color/purple_700</item>
        <item name="colorOnPrimary">@color/white</item>
        <!-- Secondary brand color. -->
        <item name="colorSecondary">@color/teal_200</item>
        <item name="colorSecondaryVariant">@color/teal_700</item>
        <item name="colorOnSecondary">@color/black</item>
        <!-- Status bar color. -->
        <item name="android:statusBarColor">?attr/colorPrimaryVariant</item>
        <!-- Customize your theme here. -->
        <item name="active_background_color">@color/white</item>
        <item name="inactive_background_color">@color/colorLightGrey</item>
        <item name="main_user_color">@color/teal_700</item>
        <item name="regular_user_color">@color/black</item>
        <item name="fab_tint_background">@color/teal_700</item>
        <item name="fab_border_color">@color/teal_700</item>
        <item name="fab_icon_color">@color/white</item>
    </style>
    <attr name="active_background_color" format="reference" />
    <attr name="inactive_background_color" format="reference" />
    <attr name="fab_tint_background" format="reference" />
    <attr name="fab_border_color" format="reference" />
    <attr name="fab_icon_color" format="reference" />
    <attr name="main_user_color" format="reference" />
    <attr name="regular_user_color" format="reference" />
    <style name="CrudDiary_LinearLayoutCompat" parent="Theme.CrudDiary">
        <item name="android:layout_width">match_parent</item>
        <item name="android:layout_height">wrap_content</item>
        <item name="android:clipToPadding">false</item>
        <item name="android:orientation">vertical</item>
        <item name="android:padding">0dp</item>
        <item name="divider">@drawable/divider</item>
        <item name="showDividers">middle</item>
    </style>
    <style name="CrudDiary_TextInputLayout" parent="Widget.MaterialComponents.TextInputLayout.OutlinedBox">
        <item name="android:layout_width">match_parent</item>
        <item name="android:layout_height">wrap_content</item>
        <item name="android:layout_marginBottom">10dp</item>
    </style>
    <style name="CrudDiary_LinearLayoutCompat_CardView" parent="Widget.MaterialComponents.CardView">
        <item name="android:layout_width">match_parent</item>
        <item name="android:layout_height">wrap_content</item>
        <item name="android:layout_margin">8dp</item>
        <item name="cardElevation">4dp</item>
        <item name="cardCornerRadius">4dp</item>
    </style>
    <style name="CrudDiary_LinearLayoutCompat_CardView_Single_Text" parent="Theme.CrudDiary">
        <item name="android:layout_width">match_parent</item>
        <item name="android:layout_height">wrap_content</item>
        <item name="android:minHeight">50dp</item>
        <item name="android:gravity">center_vertical</item>
        <item name="android:textStyle">bold</item>
        <item name="android:padding">8dp</item>
        <item name="android:textSize">16sp</item>
    </style>
    <style name="CrudDiary_LinearLayoutCompat_CardView_Text" parent="Theme.CrudDiary">
        <item name="android:layout_width">0dp</item>
        <item name="android:layout_height">wrap_content</item>
        <item name="android:layout_gravity">fill_horizontal|start</item>
        <item name="android:padding">8dp</item>
        <item name="android:textSize">16sp</item>
        <item name="android:layout_row">1</item>
        <item name="android:layout_column">0</item>
    </style>
    <style name="CrudDiary_LinearLayoutCompat_CardView_Title" parent="CrudDiary_LinearLayoutCompat_CardView_Text">
        <item name="android:textStyle">bold</item>
        <item name="android:layout_row">0</item>
    </style>
    <style name="CrudDiary_LinearLayoutCompat_CardView_Image" parent="Theme.CrudDiary">
        <item name="android:layout_width">70dp</item>
        <item name="android:layout_height">70dp</item>
        <item name="android:minWidth">70dp</item>
        <item name="android:layout_gravity">center_vertical</item>
        <item name="android:padding">8dp</item>
        <item name="android:adjustViewBounds">true</item>
        <item name="android:layout_row">0</item>
        <item name="android:layout_column">1</item>
        <item name="android:layout_rowSpan">2</item>
        <item name="android:contentDescription">@string/item_image_description</item>
    </style>
    <style name="CrudDiary_LinearLayoutCompat_CardView_Grid" parent="Theme.CrudDiary">
        <item name="android:layout_width">match_parent</item>
        <item name="android:layout_height">wrap_content</item>
        <item name="android:rowCount">2</item>
        <item name="android:columnCount">2</item>
        <item name="android:orientation">horizontal</item>
    </style>
    <style name="CrudDiary_ImageView_Items" parent="Theme.CrudDiary">
        <item name="android:id">@id/imageView</item>
        <item name="android:layout_width">70dp</item>
        <item name="android:layout_height">70dp</item>
        <item name="android:layout_gravity">center</item>
        <item name="android:adjustViewBounds">true</item>
        <item name="android:layout_margin">8dp</item>
    </style>
    <style name="CrudDiary_InactivityCheckbox" parent="Theme.CrudDiary">
        <item name="android:layout_width">wrap_content</item>
        <item name="android:layout_height">wrap_content</item>
        <item name="android:layout_gravity">center</item>
        <item name="android:text">@string/inactive_label</item>
        <item name="android:visibility">gone</item>
    </style>
    <style name="CrudDiary_GenericCheckbox" parent="Theme.CrudDiary">
        <item name="android:layout_width">wrap_content</item>
        <item name="android:layout_height">wrap_content</item>
        <item name="android:layout_gravity">center</item>
    </style>
    <style name="CrudDiary_Button" parent="Widget.MaterialComponents.Button.OutlinedButton">
        <item name="android:minHeight">60dp</item>
        <item name="android:layout_width">wrap_content</item>
        <item name="android:layout_height">wrap_content</item>
        <item name="android:layout_gravity">center</item>
    </style>
    <style name="CrudDiary_SpinnerTextInputLayout" parent="Widget.MaterialComponents.TextInputLayout.OutlinedBox">
        <item name="android:layout_width">match_parent</item>
        <item name="android:layout_height">wrap_content</item>
    </style>
    <style name="CrudDiary_SpinnerTextInputEditText" parent="Theme.CrudDiary">
        <item name="android:layout_width">match_parent</item>
        <item name="android:layout_height">wrap_content</item>
        <item name="android:focusable">false</item>
        <item name="android:inputType">none</item>
    </style>
    <style name="CrudDiary_Spinner" parent="Theme.CrudDiary">
        <item name="android:layout_width">0dp</item>
        <item name="android:layout_height">0dp</item>
        <item name="android:layout_marginTop">10dp</item>
        <item name="android:background">@android:color/transparent</item>
        <item name="android:spinnerMode">dialog</item>
    </style>
    <style name="CrudDiary_Button.SaveItem">
        <item name="android:text">@string/save_button</item>
        <item name="icon">@drawable/saveItemButtonIcon</item>
    </style>
    <style name="CrudDiary_Button.DeactivateItem">
        <item name="android:text">@string/deactivate_button</item>
        <item name="icon">@drawable/deactivateItemButtonIcon</item>
    </style>
    <style name="CrudDiary_Button.DeleteItem">
        <item name="android:text">@string/delete_button</item>
        <item name="icon">@drawable/deleteItemButtonIcon</item>
    </style>
    <style name="CrudDiary_Button.AddImage">
        <item name="android:text">@string/add_image_button</item>
        <item name="icon">@drawable/addImageButtonIcon</item>
    </style>
    <style name="CrudDiary_Button.DeleteImage">
        <item name="android:text">@string/delete_image_button</item>
        <item name="icon">@drawable/deleteImageButtonIcon</item>
        <item name="android:visibility">gone</item>
    </style>
    <style name="CrudDiary_Button.AddTag">
        <item name="android:text">@string/add_tag_button</item>
        <item name="icon">@drawable/tagIcon</item>
    </style>
    <style name="CrudDiary_NoEntries" parent="Theme.CrudDiary">
        <item name="android:layout_width">match_parent</item>
        <item name="android:layout_height">wrap_content</item>
        <item name="android:layout_gravity">start</item>
        <item name="android:visibility">gone</item>
        <item name="android:padding">8dp</item>
    </style>
    <style name="ToolbarTheme" parent="ThemeOverlay.MaterialComponents.Dark.ActionBar">
        <item name="android:textColor">@color/black</item>
    </style>
</resources>

Next, we add two (reusable) layout snippets that we then include in our fragment layout (by using the include-element). The first snippet is used for diary text input. We use an end icon to display a small microphone symbol in the TextInputLayout element since we want to enable the user to enter the text via speech input:

19.4 text_input_diary_text.xml

Listing 129: text_input_diary_text.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">
    <data>
        <variable
            name="viewModelValue"
            type="String" />
        <variable
            name="hintText"
            type="String" />
    </data>
    <merge>
        <com.google.android.material.textfield.TextInputLayout
            style="@style/CrudDiary_TextInputLayout"
            android:id="@+id/diary_text_layout"
            app:endIconMode="custom"
            app:endIconDrawable="@drawable/microphoneIcon"
            android:hint="@{hintText}">
            <com.google.android.material.textfield.TextInputEditText
                android:id="@+id/diary_text"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:inputType="textCapSentences|textMultiLine"
                android:hint="@{hintText}"
                android:text="@={viewModelValue}"/>
        </com.google.android.material.textfield.TextInputLayout>
    </merge>
</layout>

The second layout snippet is used for integer value input:

19.5 text_input_generic.xml.xml

Listing 130: text_input_generic.xml
<?xml version="1.0" encoding="utf-8"?>
<layout
    xmlns:android="http://schemas.android.com/apk/res/android">
    <data>
        <variable
            name="viewModelValue"
            type="String" />
        <variable
            name="textInputFormat"
            type="int" />
        <variable
            name="textHint"
            type="String" />
        <variable
            name="possibleDigits"
            type="String" />
    </data>
    <merge>
        <com.google.android.material.textfield.TextInputLayout
            android:id="@+id/textInputLayout"
            style="@style/CrudDiary_TextInputLayout"
            android:hint="@{textHint}">
            <com.google.android.material.textfield.TextInputEditText
                android:id="@+id/textInputEditText"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:inputType="@{textInputFormat}"
                android:digits="@{possibleDigits}"
                android:text="@={viewModelValue}" />
        </com.google.android.material.textfield.TextInputLayout>
    </merge>
</layout>

Now we define a dialog layout that pops up when the user clicks the ’Add tag’ button. It takes care of those situations where either no tags have yet been inserted into the database or all available tags have already been assigned. Besides, we add a search view so that the user can search for a given tag which is handy if the tags list is quite long.

19.6 dialog_select_tag.xml

Listing 131: image_area.xml
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
    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"
    android:orientation="vertical">
    <TextView
        android:id="@+id/tags_headline"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:gravity="center"
        android:minEms="60"
        android:textSize="16sp"
        android:padding="8dp"
        android:text="@string/headline_tags_list" />
    <androidx.appcompat.widget.SearchView
        android:id="@+id/searchView"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:layout_margin="8dp"
        android:iconifiedByDefault="false"
        android:padding="4dp"
        android:queryHint="@string/search_view_hint" />
    <androidx.recyclerview.widget.RecyclerView
        android:id="@+id/tags_list"
        android:layout_width="match_parent"
        android:layout_height="0dp"
        android:layout_weight="1"
        android:gravity="top"
        app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager" />
</LinearLayout>

Next we define the actual layout for adding new diary entries:

19.7 fragment_add_diary_entry.xml

Listing 132: fragment_add_diary_entry.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"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    tools:context="app.gedama.tutorial.cruddiary.framgent.AddDiaryEntryFragment">
    <data>
        <variable
            name="viewModel"
            type="app.gedama.tutorial.cruddiary.viewmodel.AddDiaryEntryViewModel" />
    </data>
    <ScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <androidx.appcompat.widget.LinearLayoutCompat
            style="@style/CrudDiary_LinearLayoutCompat">
            <include
                android:id="@+id/title_text_field_binding"
                app:viewModelValue="@={viewModel.newDiaryEntry.title}"
                app:textHint="@{@string/diary_title_label}"
                layout="@layout/text_input_title" />
            <include
                android:id="@+id/date_text_field_binding"
                app:hintText="@{@string/date_label}"
                app:viewModelValue="@={viewModel.newDiaryEntry.dateAsString}"
                layout="@layout/text_input_date" />
            <include
                android:id="@+id/text_text_field_binding"
                app:viewModelValue="@={viewModel.newDiaryEntry.text}"
                app:hintText="@{@string/text_label}"
                layout="@layout/text_input_diary_text" />
            <include
                android:id="@+id/spinner_persons_binding"
                app:textHint="@{@string/person_label}"
                layout="@layout/spinner_generic" />
            <include
                android:id="@+id/relevance_text_field_binding"
                app:textInputFormat="@{android.text.InputType.TYPE_CLASS_NUMBER|android.text.InputType.TYPE_NUMBER_FLAG_SIGNED}"
                app:viewModelValue="@={viewModel.relevanceDataBindingVariable}"
                app:textHint="@{@string/relevance_label}"
                app:possibleDigits="@{@string/possible_digits}"
                layout="@layout/text_input_generic" />
            <LinearLayout
                android:id="@+id/tags_area"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="vertical"
                android:visibility="gone">
                <TextView
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_gravity="start"
                    android:padding="8dp"
                    android:text="@string/tags_label"
                    android:textSize="16sp" />
                <com.google.android.flexbox.FlexboxLayout
                    android:id="@+id/chip_area"
                    android:layout_width="match_parent"
                    android:layout_height="wrap_content"
                    app:flexWrap="wrap"
                    app:alignItems="stretch"
                    app:alignContent="stretch" />
            </LinearLayout>
            <com.google.android.material.button.MaterialButton
                android:id="@+id/add_tag_button"
                style="@style/CrudDiary_Button.AddTag"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:layout_gravity="center"
                android:text="@string/add_tag_button" />
            <include
                android:id="@+id/check_box_binding"
                app:viewModelValue="@={viewModel.newDiaryEntry.inactive}"
                layout="@layout/checkbox_inactive" />
            <include
                android:id="@+id/image_area_binding"
                layout="@layout/image_area" />
            <include
                app:viewModelFunc="@{ () -> viewModel.addDiaryEntry() }"
                android:id="@+id/crud_buttons"
                app:visibility="@{android.view.View.GONE}"
                layout="@layout/crud_buttons" />
        </androidx.appcompat.widget.LinearLayoutCompat>
    </ScrollView>
</layout>

As you can see, we have reused several existing layout snippets and the new text_input_diary_text.xml layout file. There is also a tags area where all the assigned tags are shown as chips. This tags area is invisible as long as there are no tags assigned. Below this area we have a button for adding tags. Since we use a FlexboxLayout in our tags area, we have to adapt our build.xml file:

19.8 build.xml (App)

Listing 133: build.xml
...
    // image picking and cropping
    implementation ’com.github.dhaval2404:imagepicker:2.1’
    // in order to use FlexboxLayout
    implementation ’com.google.android.flexbox:flexbox:3.0.0’
    testImplementation ’junit:junit:4.13.2’
...
}

Because we need three additional form field validations, let’s extend our existing Validation.kt class with the new variables textValidation, personValidation and relevanceValidation. They make sure that the user enters an actual diary text, assigns a person to whom this diary entry belongs, and enters a valid relevance value, respectively:

19.9 Validation.kt

Listing 134: Validation.kt
package app.gedama.tutorial.cruddiary.data
import androidx.core.text.isDigitsOnly
import app.gedama.tutorial.cruddiary.GlobalAppStore
import app.gedama.tutorial.cruddiary.R
class Validation {
    companion object {
        val nameValidation = { value: String? ->
            when {
                value.isNullOrBlank() -> getResourceText(R.string.validation_no_name)
                else -> null
            }
        }
        val birthdayValidation = { value: String? ->
            when {
                value.isNullOrBlank() -> getResourceText(R.string.validation_no_birthdate)
                else -> null
            }
        }
        val genderValidation = { value: String? ->
            when {
                value.equals(getResourceText(R.string.nothing_selected)) -> getResourceText(R.string.validation_no_gender)
                else -> null
            }
        }
        val titleValidation = { value: String? ->
            when {
                value.isNullOrBlank() -> getResourceText(R.string.validation_no_title)
                else -> null
            }
        }
        val textValidation = { value: String? ->
            when {
                value.isNullOrBlank() -> getResourceText(R.string.validation_no_text)
                else -> null
            }
        }
        val personValidation = { value: String? ->
            when {
                value.equals(getResourceText(R.string.nothing_selected)) -> getResourceText(R.string.validation_no_person)
                else -> null
            }
        }
        val relevanceValidation = { value: String? ->
            when {
                value.isNullOrBlank() -> getResourceText(R.string.validation_no_relevance)
                !value.isDigitsOnly() -> getResourceText(R.string.validation_relevance_digits)
                value.toInt() < 1 -> getResourceText(R.string.validation_relevance_positive_number)
                else -> null
            }
        }
        private fun getResourceText(id: Int) =
            GlobalAppStore.appCtx!!.resources.getText(id).toString()
    }
}

Next we add a new method doSpeechRecognition() to our BaseFragment class which handles speech input:

19.10 BaseFragment.kt

Listing 135: BaseFragment.kt
package app.gedama.tutorial.cruddiary.fragment
import android.app.Activity
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.os.Build
import android.speech.RecognizerIntent
import android.util.Log
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.CheckBox
import android.widget.ImageView
import android.widget.LinearLayout
import android.widget.Spinner
import android.widget.TableRow
import android.widget.Toast
import androidx.activity.result.ActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog
import androidx.core.graphics.drawable.toBitmap
import androidx.core.view.isInvisible
import app.gedama.tutorial.cruddiary.R
import androidx.core.view.isVisible
import androidx.fragment.app.Fragment
import androidx.lifecycle.LiveData
import androidx.lifecycle.lifecycleScope
import app.gedama.tutorial.cruddiary.GlobalAppStore
import app.gedama.tutorial.cruddiary.data.Spinnable
import app.gedama.tutorial.cruddiary.ui.formfields.FormFieldText
import app.gedama.tutorial.cruddiary.ui.formfields.validate
import app.gedama.tutorial.cruddiary.viewmodel.BaseViewModel
import com.google.android.material.button.MaterialButton
import com.google.android.material.floatingactionbutton.FloatingActionButton
import com.google.android.material.textfield.TextInputEditText
import kotlinx.coroutines.launch
import java.io.File
import com.github.dhaval2404.imagepicker.ImagePicker
import com.google.android.material.textfield.TextInputLayout
abstract class BaseFragment: Fragment() {
    val TAG = this::class.simpleName
    fun saveToUpdateButton(saveButton: Button) {
        saveButton.text = resources.getString(R.string.update_button)
    }
    fun setDeactivateButton(deactivateButton: MaterialButton, viewModelFunc: () -> Unit ) {
        deactivateButton.setOnClickListener {
            val builder = AlertDialog.Builder(this.requireContext())
            builder.setTitle(resources.getText(R.string.inactivity_safety_dialog_title))
            builder.setMessage(resources.getText(R.string.inactivity_safety_check))
            builder.setPositiveButton(R.string.yes) { _, _ ->
                viewModelFunc()
            }
            builder.setNegativeButton(R.string.no) { _, _ ->
                // do nothing, but we need this statement to show the No-Button
            }
            builder.show()
        }
    }
    fun setInactivityButton(inactive: Boolean, deactivateButton: MaterialButton, checkBoxInactive: CheckBox) {
        if ( inactive ) {
            deactivateButton.visibility = View.GONE
            checkBoxInactive.isVisible = true
        }
    }
    fun activateAddFAB(viewModelFunc: () -> Unit ) {
        val fab = this.requireActivity().findViewById<FloatingActionButton>(R.id.add_floating_action_button)
        fab.isVisible = true
        fab.setOnClickListener { viewModelFunc() }
    }
    fun deactivateAddFAB() {
        val fab = this.requireActivity().findViewById<FloatingActionButton>(R.id.add_floating_action_button)
        fab.isVisible = false
    }
    fun formValidation(actionButton: Button, formFields: List<FormFieldText>, viewModel: BaseViewModel) = lifecycleScope.launch {
        actionButton.isEnabled = false
        // formFields.disable()
        if (formFields.validate(validateAll = true)) {
            viewModel.updateEntryAfterValidation()
            viewModel.onValidatedAndUpdated()
        } else {
            viewModel._deactivateSuccess.value = false
        }
        // formFields.enable()
        actionButton.isEnabled = true
    }
    fun initMaterialSpinnerData(spinner: Spinner, textEdit: TextInputEditText, listOfItems: LiveData<List<Spinnable>>, viewModelFunc: (item: Spinnable) -> Unit ) {
        textEdit.setOnClickListener {
            spinner.performClick()
        }
        val allItems = context?.let {
            ArrayAdapter<Any>(it, android.R.layout.simple_dropdown_item_1line)
        }
        listOfItems
            .observe(viewLifecycleOwner, { items ->
                items?.forEach {
                    allItems?.add(it)
                }
            })
        spinner.adapter = allItems
        spinner.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
            override fun onItemSelected(
                parent: AdapterView<*>?,
                view: View?,
                position: Int,
                id: Long
            ) {
                val selectedItem = parent!!.adapter.getItem(position) as Spinnable
                textEdit.setText(selectedItem.itemName())
                viewModelFunc(selectedItem)
            }
            override fun onNothingSelected(parent: AdapterView<*>?) {
                TODO("Not yet implemented")
            }
        }
    }
    // from https://github.com/Dhaval2404/ImagePicker
    fun initImagePicking(iv: ImageView, ib: Button, viewModel: BaseViewModel, standardImageButton: Button? = null,
                         maxSize: Int = 1024, maxWidth: Int = 1080, maxHeight: Int = 1080, cropX: Float = 1F, cropY: Float = 1F) {
        val startForProfileImageResult =
            registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
                val resultCode = result.resultCode
                val data = result.data
                if (resultCode == Activity.RESULT_OK) {
                    //Image Uri will not be null for RESULT_OK
                    val fileUri = data?.data!!
                    iv.setImageURI(fileUri)
                    Log.d(TAG,"URI of selected image: $fileUri")
                    val bitmap = iv.drawable.toBitmap()
                    viewModel.imageBitmap = bitmap
                    viewModel.defaultImage = false
                    ib.text = resources.getText(R.string.change_image_button)
                    var parentView = iv.parent
                    if ( parentView is TableRow) {
                        parentView = iv.parent as TableRow
                        parentView.isInvisible = false
                    }
                    if ( parentView is LinearLayout) {
                        parentView = iv.parent as LinearLayout
                        parentView.isInvisible = false
                    }
                    standardImageButton?.let {
                        it.isInvisible = false
                    }
                } else if (resultCode == ImagePicker.RESULT_ERROR) {
                    Log.e(TAG, ImagePicker.getError(data))
                    Toast.makeText(this.requireContext(), ImagePicker.getError(data), Toast.LENGTH_SHORT).show()
                } else {
                    Toast.makeText(this.requireContext(), resources.getText(R.string.image_picking_canceled), Toast.LENGTH_SHORT).show()
                }
            }
        ib.setOnClickListener {
            ImagePicker.with(this)
                .compress(maxSize)         //Final image size will be less than 1 MB(Optional)
                .maxResultSize(maxWidth, maxHeight)  //Final image resolution will be less than 1080 x 1080(Optional)
                .crop(cropX,cropY)
                .createIntent { intent ->
                    startForProfileImageResult.launch(intent)
                }
        }
    }
    private fun loadImage(imagePath: String): Bitmap? {
        var bm: Bitmap? = null
        if (imagePath.isNotBlank()) {
            bm = getBitmap(imagePath)
        }
        return bm
    }
    private fun getBitmap(imagePath: String): Bitmap? {
        val file = File(GlobalAppStore.appCtx!!.filesDir, imagePath)
        return BitmapFactory.decodeFile(file.absolutePath)
    }
    fun setDeleteImageButton(deleteImageButton: MaterialButton, iv: ImageView, ivC: LinearLayout, buttonLoadPicture: MaterialButton, viewModel: BaseViewModel) {
        deleteImageButton.setOnClickListener {
            viewModel.imageBitmap = null
            iv.setImageBitmap(null)
            ivC.visibility = View.GONE
            buttonLoadPicture.text = resources.getText(R.string.add_image_button)
            buttonLoadPicture.setIconResource(R.drawable.addImageButtonIcon)
            deleteImageButton.visibility = View.GONE
            viewModel.defaultImage = true
        }
    }
    fun setChangeImageButton(view: View, imagePath: String, viewModel: BaseViewModel) {
        val iv = view.findViewById<ImageView>(R.id.imageView)
        val ivC = view.findViewById<LinearLayout>(R.id.image_view_container)
        val buttonLoadPicture = view.findViewById<MaterialButton>(R.id.buttonLoadPicture)
        val buttonDefaultPicture = view.findViewById<Button>(R.id.buttonDefaultPicture)
        viewModel.setOldImage( imagePath )
        val bm = loadImage( imagePath )
        if ( bm != null ) {
            iv.setImageBitmap(bm)
            ivC.isInvisible = false
            buttonLoadPicture.text = resources.getText(R.string.change_image_button)
            buttonLoadPicture.setIconResource(R.drawable.changeImageButtonIcon)
            buttonDefaultPicture.isInvisible = false
            viewModel.defaultImage = false
        }
    }
    fun doSpeechRecognition(layoutEndIcon: TextInputLayout, textField: TextInputEditText ) {
        val startForSpeechRecognitionResult =
            registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result: ActivityResult ->
                val resultCode = result.resultCode
                val data = result.data
                if (resultCode == Activity.RESULT_OK) {
                    val spokenText: String? =
                        data!!.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS).let { results ->
                            results!![0]
                        }
                    spokenText?.let {
                        var newText: String = textField.text.toString()
                        if ( newText.isBlank() ) {
                            newText = it
                        } else {
                            newText += " " + it
                        }
                        Log.d(TAG,"Recognized text: $it")
                        textField.setText(newText)
                    }
                } else {
                    Log.d(TAG,"Speech recogintion was not successful: $resultCode")
                }
            }
        layoutEndIcon.setEndIconOnClickListener {
            val intent = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH).apply {
                putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
                // from API level 33 on this should improve the formatting (capitalization, punctuation, etc.) of the recognized text
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
                    putExtra(
                        RecognizerIntent.EXTRA_ENABLE_FORMATTING,
                        RecognizerIntent.FORMATTING_OPTIMIZE_QUALITY
                    )
                }
            }
            startForSpeechRecognitionResult.launch(intent)
        }
    }
}

Now it is time to present our AddDiaryEntryFragment class:

19.11 AddDiaryEntryFragment.kt

Listing 136: AddDiaryEntryFragment.kt
package app.gedama.tutorial.cruddiary.fragment
import android.app.DatePickerDialog
import android.app.Dialog
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.appcompat.widget.SearchView
import androidx.core.view.isVisible
import androidx.lifecycle.LiveData
import androidx.lifecycle.Observer
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.lifecycleScope
import androidx.navigation.findNavController
import androidx.recyclerview.widget.RecyclerView
import app.gedama.tutorial.cruddiary.GlobalAppStore
import app.gedama.tutorial.cruddiary.R
import app.gedama.tutorial.cruddiary.adapter.TagItemAdapter
import app.gedama.tutorial.cruddiary.data.Spinnable
import app.gedama.tutorial.cruddiary.data.Validation
import app.gedama.tutorial.cruddiary.database.CrudDiaryDatabase
import app.gedama.tutorial.cruddiary.databinding.FragmentAddDiaryEntryBinding
import app.gedama.tutorial.cruddiary.ui.LambdaDatePicker
import app.gedama.tutorial.cruddiary.ui.formfields.FormFieldText
import app.gedama.tutorial.cruddiary.viewmodel.AddDiaryEntryViewModel
import app.gedama.tutorial.cruddiary.viewmodel.AddDiaryEntryViewModelFactory
import com.google.android.material.chip.Chip
import java.text.DateFormat
import java.util.*
class AddDiaryEntryFragment : BaseFragment() {
    private var _binding: FragmentAddDiaryEntryBinding? = null
    private val binding get() = _binding!!
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentAddDiaryEntryBinding.inflate(inflater, container, false)
        val view = binding.root
        val application = requireNotNull(this.activity).application
        val diaryEntryDao = CrudDiaryDatabase.getInstance(application).diaryEntryDao
        val viewModelFactory = AddDiaryEntryViewModelFactory(diaryEntryDao)
        val viewModel = ViewModelProvider(this, viewModelFactory).get(AddDiaryEntryViewModel::class.java)
        binding.viewModel = viewModel
        binding.lifecycleOwner = viewLifecycleOwner
        // form validation, see https://medium.com/@asissuthar/simplify-form-validation-using-kotlin-flow-on-android-16c718e3efaa
        val fieldTitle = FormFieldText(
            scope = lifecycleScope,
            textInputLayout = binding.titleTextFieldBinding.titleLayout,
            textInputEditText = binding.titleTextFieldBinding.title,
            Validation.titleValidation
        )
        val fieldText = FormFieldText(
            scope = lifecycleScope,
            textInputLayout = binding.textTextFieldBinding.diaryTextLayout,
            textInputEditText = binding.textTextFieldBinding.diaryText,
            Validation.textValidation
        )
        val fieldPerson = FormFieldText(
            scope = lifecycleScope,
            textInputLayout = binding.spinnerPersonsBinding.spinnerInputLayoutId,
            textInputEditText = binding.spinnerPersonsBinding.spinnerTextId,
            Validation.personValidation
        )
        val fieldRelevance = FormFieldText(
            scope = lifecycleScope,
            textInputLayout = binding.relevanceTextFieldBinding.textInputLayout,
            textInputEditText = binding.relevanceTextFieldBinding.textInputEditText,
            Validation.relevanceValidation
        )
        val formFields = listOf(fieldTitle,fieldText,fieldPerson,fieldRelevance)
        initImagePicking(binding.imageAreaBinding.imageView, binding.imageAreaBinding.buttonLoadPicture,viewModel, binding.imageAreaBinding.buttonDefaultPicture)
        val dialogBox = Dialog(this.requireContext())
        val adapter = TagItemAdapter { tag ->
            viewModel.setSelectedTag(tag)
            dialogBox.cancel()
        }
        viewModel.allTags.observe(viewLifecycleOwner, Observer {
            it?.let {
                adapter.setData(it.toMutableList())
                viewModel.setSelectableTags(it.toSet())
            }
        })
        // when a tag is added to the diary entry, the list of selectable tags
        // has to be reduced by this added tag and the adapter has to be updated correspondingly
        viewModel.selectableTags.observe(viewLifecycleOwner, Observer {
            Log.d(TAG, "selectableTags was changed ...")
            it?.let {
                Log.d(TAG, "New selectable tag list: ${it}")
                adapter.submitList(it.toList())
            }
        })
        viewModel.selectedTags.observe(viewLifecycleOwner, Observer {
            Log.d(TAG, "selectedTags was changed ...")
            it?.let {
                Log.d(TAG, "New selected tag list: ${it}")
                if ( it.isNotEmpty() ) {
                    binding.tagsArea.isVisible = true
                    binding.chipArea.removeAllViews()
                    it.forEach { tag ->
                        val chip = Chip(this.requireContext())
                        chip.text = tag.title
                        chip.isCloseIconVisible = true
                        chip.setOnClickListener { view ->
                            viewModel.deSelectTag(tag)
                            binding.chipArea.removeView(view)
                        }
                        binding.chipArea.addView(chip)
                    }
                } else {
                    binding.tagsArea.visibility = View.GONE
                }
            }
        })
        viewModel.navigateToList.observe(viewLifecycleOwner, Observer { navigate ->
            if (navigate) {
                view.findNavController()
                    .navigate(R.id.action_addDiaryEntryFragment_to_diaryEntriesListFragment)
                viewModel.onNavigatedToList()
            }
        })
        viewModel.doValidation.observe(viewLifecycleOwner, Observer { doValidate ->
            if (doValidate) {
                formValidation(binding.crudButtons.actionButton, formFields, viewModel)
            }
        })
        viewModel.newEntryDate.observe(viewLifecycleOwner, Observer { item ->
            binding.dateTextFieldBinding.date.setText(DateFormat.getDateInstance(DateFormat.MEDIUM).format(item))
        })
        @Suppress("UNCHECKED_CAST")
        initMaterialSpinnerData(binding.spinnerPersonsBinding.spinnerId,binding.spinnerPersonsBinding.spinnerTextId,
            viewModel.allPersons as LiveData<List<Spinnable>>,{ item: Spinnable -> viewModel.updateDiaryPerson(item)} )
        viewModel.allPersons.observe(viewLifecycleOwner, { it ->
            val mainUserId = GlobalAppStore.mainUserId ?: 1L
            val mainPerson = it.find{ it.itemId() == mainUserId }
            val mainPersonPos = it.indexOf(mainPerson)
            binding.spinnerPersonsBinding.spinnerId.setSelection(mainPersonPos)
        })
        val handleIconClick = object : View.OnClickListener {
            override fun onClick(v: View?) {
                val picker = LambdaDatePicker(context!!, showTimePicker = false, { item: Date -> viewModel.updateEntryDate(item) })
                val currentDate = Calendar.getInstance()
                val datePickerDialog =
                    DatePickerDialog(
                        context!!,
                        picker,
                        currentDate.get(Calendar.YEAR),
                        currentDate.get(
                            Calendar.MONTH
                        ),
                        currentDate.get(Calendar.DAY_OF_MONTH)
                    )
                datePickerDialog.datePicker.maxDate = Date().time
                datePickerDialog.show()
            }
        }
        binding.dateTextFieldBinding.date.setOnClickListener(handleIconClick)
        binding.dateTextFieldBinding.dateLayout.setEndIconOnClickListener(handleIconClick)
        doSpeechRecognition(binding.textTextFieldBinding.diaryTextLayout, binding.textTextFieldBinding.diaryText)
        // see https://github.com/zinedineBenkhider/android-tutos/blob/main/CustomizedDialogBox/app/src/main/java/com/example/customizeddialogbox/MainActivity.kt
        val tagButton = binding.addTagButton
        tagButton.setOnClickListener {
            dialogBox.setContentView(R.layout.dialog_select_tag)
            val searchView = dialogBox.findViewById<SearchView>(R.id.searchView)
            val sac = searchView.findViewById<SearchView.SearchAutoComplete>(androidx.appcompat.R.id.search_src_text)
            sac.setHint(resources.getString(R.string.search_view_hint))
            val headline = dialogBox.findViewById<TextView>(R.id.tags_headline)
            val listSize = adapter.itemCount
            if ( listSize < 1 && viewModel.selectedTags.value!!.isEmpty() ) {
                headline.text = getString(R.string.no_tags_available)
            } else if ( listSize < 1 && viewModel.selectedTags.value!!.size > 0 ) {
                headline.text = getString(R.string.no_further_tags_available)
            }
            val tagRV = dialogBox.findViewById<RecyclerView>(R.id.tags_list)
            tagRV.adapter = adapter
            searchView.setOnQueryTextListener(object: SearchView.OnQueryTextListener {
                override fun onQueryTextSubmit(p0: String?): Boolean {
                    adapter.filter.filter(p0)
                    return false
                }
                override fun onQueryTextChange(p0: String?): Boolean {
                    adapter.filter.filter(p0)
                    return false
                }
            })
            dialogBox.show()
        }
        setDeleteImageButton(binding.imageAreaBinding.buttonDefaultPicture,binding.imageAreaBinding.imageView,
            binding.imageAreaBinding.imageViewContainer,binding.imageAreaBinding.buttonLoadPicture, viewModel)
        return view
    }
    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

********************* To Do: Explain code in above fragment

For getting from the diary entries list page to the ’Add diary entry’ page in our UI, two more tasks have to be done. First, we have to extend our existing DiaryEntriesListFragment:

19.12 DiaryEntriesListFragment.kt

Listing 137: DiaryEntriesListFragment.kt
...
        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
            }
        })
        viewModel.navigateToAddItem.observe(viewLifecycleOwner, { navigate ->
            if (navigate) {
                val action = DiaryEntriesListFragmentDirections
                    .actionDiaryEntriesListFragmentToAddDiaryEntryFragment()
                this.findNavController().navigate(action)
                viewModel.onItemAddNavigated()
            }
        })
        return view
...

And second we have to extend our navigation graph with our new fragment.

19.13 nav_graph.xml

Listing 138: 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" />
        <action
            android:id="@+id/action_diaryEntriesListFragment_to_addDiaryEntryFragment"
            app:destination="@id/addDiaryEntryFragment" />
    </fragment>
    <fragment
        android:id="@+id/addDiaryEntryFragment"
        android:name="app.gedama.tutorial.cruddiary.fragment.AddDiaryEntryFragment"
        android:label="@string/diary_entries_add_menu_title" >
        <action
            android:id="@+id/action_addDiaryEntryFragment_to_diaryEntriesListFragment"
            app:destination="@id/diaryEntriesListFragment"
            app:popUpTo="@id/homeFragment" />
    </fragment>
</navigation>

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 FAB at the bottom of the diary entries list, you get to the input mask for adding a new diary entry (see Figure 19.1). Please notice that Lucy is already assigned as the person to whom this diary entry belongs. This is because we set Lucy as the main user in the preferences menu.

If you click on the ’Add tag’ button, a dialog is presented containing all assignable tags (see Figure 19.2).

Dialog widget for tag assignment
Figure 19.2: Dialog widget for tag assignment

Tags can only be assigned once, therefore if you reclick the ’Add tag’ button, you will be presented only with tags that haven’t been assigned so far. After selecting tags, these are presented in the tags area above the ’Add tag’ button (see Figure 19.3).

Assigned tags are shown in the tags area
Figure 19.3: Assigned tags are shown in the tags area

After adding a diary entry you return to the diary entries list containing the item just added (see Figure 19.4).

Diary entries list after adding an item
Figure 19.4: Diary entries list after adding an item