How to Build a CRUD App With Kotlin and Android Studio

Chapter 20 PDF Files

Problems covered in this chapter
  • Saving images and PDF files

  • Basic fragment class with useful methods for all further fragments

  • Basic view model class with useful methods for all further view models

In the last chapter we covered adding diary entries. The diary text can be entered either via typing or by speech input. However, we would like to give the user the choice of providing additional text in form of a PDF file.

Let’s start with the layout part. First, we add a new vector asset to our drawables folder: baseline_picture_as_pdf_24

Next we extend our string.xml files:

20.1 string.xml (English)

Listing 139: 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>
    <string name="pdf_label">PDF</string>
    <string name="add_pdf_button">Add PDF</string>
    <string name="delete_pdf_button">Delete PDF</string>
    <string name="change_pdf_button">Change PDF</string>
    <string name="select_app">Select app</string>
</resources>

Next, we extend our themes.xml file with one new drawable-definition and two new styles CrudDiary_Button.AddPdf and CrudDiary_Button.DeletePdf:

20.2 themes.xml (Light-Mode)

Listing 140: 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>
    <drawable name="pdfIcon">@drawable/baseline_picture_as_pdf_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_Button.AddPdf">
        <item name="android:text">@string/add_pdf_button</item>
        <item name="icon">@drawable/pdfIcon</item>
    </style>
    <style name="CrudDiary_Button.DeletePdf">
        <item name="android:text">@string/delete_pdf_button</item>
        <item name="icon">@drawable/deleteItemButtonIcon</item>
        <item name="android:visibility">gone</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 another reusable layout snippet that we then include in our fragment layout (by using the include-element). This snippet is similar to the image_area.xml but in this case it contains a TextInputEditText to display the PDF filename and buttons for adding/changing and deleting PDF files. We use an end icon to display a small PDF symbol in the TextInputLayout element. When the user clicks on this icon, the device’s PDF app is triggered to display the file contents.

20.3 pdf_area.xml

Listing 141: pdf_area.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>
    <LinearLayout
        android:id="@+id/pdf_view_container"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        android:visibility="gone"
        android:orientation="vertical">
        <com.google.android.material.textfield.TextInputLayout
            style="@style/CrudDiary_TextInputLayout"
            android:id="@+id/pdf_layout"
            app:endIconMode="custom"
            app:endIconDrawable="@drawable/pdfIcon"
            android:hint="@{hintText}">
            <com.google.android.material.textfield.TextInputEditText
                android:id="@+id/pdf_file_name"
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:enabled="false"
                android:hint="@{hintText}"
                android:text="@={viewModelValue}"/>
        </com.google.android.material.textfield.TextInputLayout>
    </LinearLayout>
    <LinearLayout
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_gravity="center"
        android:divider="@drawable/button_space"
        android:orientation="horizontal"
        android:showDividers="middle">
        <com.google.android.material.button.MaterialButton
            android:id="@+id/add_pdf_button"
            style="@style/CrudDiary_Button.AddPdf" />
        <com.google.android.material.button.MaterialButton
            android:id="@+id/delete_pdf_button"
            style="@style/CrudDiary_Button.DeletePdf" />
    </LinearLayout>
    </merge>
</layout>

Next we adapt the layout for adding new diary entries:

20.4 fragment_add_diary_entry.xml

Listing 142: 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/pdf_area_binding"
                app:viewModelValue="@={viewModel.newDiaryEntry.pdfFileName}"
                app:hintText="@{@string/pdf_label}"
                layout="@layout/pdf_area" />
            <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 include our previously defined pdf_area layout snippet.

Next we add new methods to our BaseFragment class which handle PDF input:

20.5 BaseFragment.kt

Listing 143: 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.net.Uri
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.ActivityResultLauncher
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
        }
    }
    protected fun initPDFFromStorage(pdfB: MaterialButton, pdfDeleteB: MaterialButton, pvC: LinearLayout, viewModel: BaseViewModel) {
        val selectPDFFromStorageResult = registerForActivityResult(ActivityResultContracts.GetContent()) { uri: Uri? ->
            uri?.let {
                Log.d(TAG,"URI of selected PDF: $uri")
                viewModel.setPdfUri(uri)
                viewModel.pdfFileName = viewModel.getFilenameFromUri(uri)
                pvC.isVisible = true
                pdfB.text = getString(R.string.change_pdf_button)
                pdfDeleteB.isVisible = true
                setDeletePdfButton(pdfDeleteB,pvC,pdfB,viewModel)
            }
        }
        pdfB.setOnClickListener {
            selectPDFFromStorage(selectPDFFromStorageResult)
        }
    }
    private fun selectPDFFromStorage(selectPDFFromStorageResult: ActivityResultLauncher<String>) = selectPDFFromStorageResult.launch("application/pdf")
    private fun setDeletePdfButton(deletePdfButton: MaterialButton, pvC: LinearLayout, buttonAddPdf: MaterialButton, viewModel: BaseViewModel) {
        deletePdfButton.setOnClickListener {
            viewModel.setPdfUri(null)
            pvC.visibility = View.GONE
            buttonAddPdf.text = resources.getText(R.string.add_pdf_button)
            deletePdfButton.visibility = View.GONE
        }
    }
    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)
        }
    }
}

And we adapt our BaseViewModel class to define a pdfUri variable and several methods for handling PDF files:

20.6 BaseViewModel.kt

Listing 144: BaseViewModel.kt
package app.gedama.tutorial.cruddiary.viewmodel
import android.content.Context
import android.graphics.Bitmap
import android.net.Uri
import android.provider.OpenableColumns
import android.util.Log
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import app.gedama.tutorial.cruddiary.GlobalAppStore
import java.io.ByteArrayOutputStream
import java.io.File
import java.util.Date
abstract class BaseViewModel: ViewModel() {
    val TAG = this::class.simpleName
    val _navigateToList = MutableLiveData(false)
    val navigateToList: LiveData<Boolean>
        get() = _navigateToList
    val _navigateToAddItem = MutableLiveData(false)
    val navigateToAddItem: LiveData<Boolean>
        get() = _navigateToAddItem
    val _navigateToEditItem= MutableLiveData<Long?>()
    val navigateToEditItem: LiveData<Long?>
        get() = _navigateToEditItem
    val _insertSuccess = MutableLiveData<Boolean?>(null)
    val insertSuccess: LiveData<Boolean?>
        get() = _insertSuccess
    val _deactivateSuccess = MutableLiveData(false)
    val deactivateSuccess: LiveData<Boolean>
        get() = _deactivateSuccess
    protected val _doValidation = MutableLiveData<Boolean>(false)
    val doValidation: LiveData<Boolean>
        get() = _doValidation
    private val _oldImage = MutableLiveData<String>("")
    val oldImage: LiveData<String>
        get() = _oldImage
    var imageBitmap: Bitmap? = null
    var defaultImage = true
    var pdfFileName: String = ""
    var _pdfUri = MutableLiveData<Uri?>()
    val pdfUri: LiveData<Uri?>
        get() = _pdfUri
    fun addItem() {
        _navigateToAddItem.value = true
    }
    fun onNavigatedToList() {
        _navigateToList.value = false
    }
    fun onItemClicked(itemId: Long) {
        _navigateToEditItem.value = itemId
    }
    fun onItemAddNavigated() {
        _navigateToAddItem.value = false
    }
    fun onItemEditNavigated() {
        _navigateToEditItem.value = null
    }
    fun onValidatedAndUpdated() {
        _doValidation.value = false
    }
    fun setOldImage(oi: String) {
        _oldImage.value = oi
    }
    fun setPdfUri(newPdfUri: Uri?) {
        _pdfUri.value = newPdfUri
    }
    fun writeImage(filenameprefix: String): String {
        val currentTimeInMillis = System.currentTimeMillis()
        val fileName = GlobalAppStore.APP_FILEPATH_PREFIX + filenameprefix + currentTimeInMillis + ".jpg"
        val stream = ByteArrayOutputStream()
        imageBitmap!!.compress(Bitmap.CompressFormat.JPEG, 100, stream)
        val bitmapdata: ByteArray = stream.toByteArray()
        val context = GlobalAppStore.appCtx
        context!!.openFileOutput(fileName, Context.MODE_PRIVATE).use {
            it.write(bitmapdata)
        }
        return fileName
    }
    fun writePdf(filenameprefix: String): String {
        val currentTimeInMillis = System.currentTimeMillis()
        val fileName = GlobalAppStore.APP_FILEPATH_PREFIX  + filenameprefix + currentTimeInMillis + ".pdf"
        val context = GlobalAppStore.appCtx
        val inputStream = context!!.contentResolver.openInputStream(pdfUri.value!!)
        val fos = context.openFileOutput(fileName, Context.MODE_PRIVATE)
        fos.write(inputStream!!.readBytes())
        inputStream.close()
        return fileName
    }
    fun getFilenameFromUri(uri: Uri) : String {
        var fn = "file.pdf"
        GlobalAppStore.appCtx!!.contentResolver.query(uri, null, null, null, null)?.use { cursor ->
            /*
             * Get the column indexes of the data in the Cursor,
             * move to the first row in the Cursor, get the data,
             * and display it.
             */
            val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME)
            val sizeIndex = cursor.getColumnIndex(OpenableColumns.SIZE)
            cursor.moveToFirst()
            fn = cursor.getString(nameIndex)
            Log.d(TAG,"Real file name: ${fn}")
            Log.d(TAG,"File size: ${cursor.getLong(sizeIndex).toString()}")
        }
        // fn = File("" + Uri.parse(pdfUri.value!!.lastPathSegment)).name
        Log.d(TAG,"Obtained filename: $fn")
        return fn
    }
    private fun deleteOldImage(filename: String) {
        val context = GlobalAppStore.appCtx
        val file = File(context!!.filesDir, filename)
        val result = file.delete()
        Log.d(TAG, "Result from deleting $filename: $result")
    }
    fun checkNewImagePath(filenameprefix: String): String? {
        var newValue: String? = null
        // handle several cases
        if ( oldImage.value!!.isNotBlank() && defaultImage ) {
            // a former image was deleted
            deleteOldImage(oldImage.value!!)
            newValue = ""
        } else if ( oldImage.value!!.isNotBlank() && imageBitmap != null ) {
            // a former image was replaced by a new one
            deleteOldImage(oldImage.value!!)
            newValue = writeImage(filenameprefix)
        } else if ( oldImage.value!!.isBlank() && imageBitmap != null ) {
            // a new image was added
            newValue = writeImage(filenameprefix)
        }
        return newValue
    }
    open fun updateEntryDate(date: Date) {
        // to be overridden in view models with a date property
    }
    open fun updateEntryAfterValidation() {
        // to be overridden by all view models that do validation
    }
}

Now we can adapt our existing AddDiaryEntryViewModel class:

20.7 AddDiaryEntryViewModel.kt

Listing 145: 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_")
            }
            if ( pdfUri != null && pdfUri.value != null ) {
                newDiaryEntry.pdfFileName = getFilenameFromUri(pdfUri.value!!)
                newDiaryEntry.pdfPath = writePdf("pdf_")
            }
            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")
    }
}

The only thing that has changed is within the updateEntryAfterValidation() method where we added code to check if a PDF file was added in which case we set the corresponding instance variables of our diary entry data object and write the PDF file to the internal storage of our app (similar to handling image files).

And finally we adapt our AddDiaryEntryFragment class:

20.8 AddDiaryEntryFragment.kt

Listing 146: AddDiaryEntryFragment.kt
package app.gedama.tutorial.cruddiary.fragment
import android.app.DatePickerDialog
import android.app.Dialog
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Button
import android.widget.TextView
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
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 app.gedama.tutorial.cruddiary.viewmodel.BaseViewModel
import com.google.android.material.button.MaterialButton
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())
            }
        })
        viewModel.pdfUri.observe(viewLifecycleOwner, { pdfUri ->
            if ( pdfUri != null ) {
                binding.pdfAreaBinding.pdfFileName.setText(viewModel.pdfFileName)
                binding.pdfAreaBinding.pdfLayout.setEndIconOnClickListener {
                    val intent = Intent(Intent.ACTION_VIEW).apply {
                        addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
                        addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
                        setDataAndType(pdfUri, "application/pdf")
                    }
                    this.requireContext()
                        .startActivity(Intent.createChooser(intent, getString(R.string.select_app)))
                }
            }
        })
        // 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)
        val pdfButton = binding.pdfAreaBinding.addPdfButton
        val pdfDeleteButton = binding.pdfAreaBinding.deletePdfButton
        val pdfViewContainer = binding.pdfAreaBinding.pdfViewContainer
        initPDFFromStorage(pdfButton, pdfDeleteButton, pdfViewContainer, viewModel)
        return view
    }
    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
}

We observe the LiveData variable pdfUri from our view model which gets triggered when a PDF is added/changed or deleted. In the first case we display the PDF’s filename in the corresponding textInputEditText field and set a click handler to the end icon which triggers the device’s PDF app to show the PDF contents. And we initialize the buttons for PDF handling by calling initPDFFromStorage() in our BaseFragment class.

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 including the new ’Add PDF’ button (see Figure 20.1).

New ’Add PDF’ button
Figure 20.1: New ’Add PDF’ button

If you click on the ’Add PDF’ button, a file browser is presented for choosing a PDF from your device’s file system. When you select a PDF, its filename is inserted into the PDF TextInputEditText field (see Figure 20.2). Notice the PDF icon at the end of the field: if you tap on it, the device’s PDF app gets triggered to show the PDF’s contents. Notice also the buttons below the field: The ’Add PDF’ button has changed to a ’Change PDF’ button and a new ’Delete PDF’ button has emerged.

PDF file was added
Figure 20.2: PDF file was added