How to Build a CRUD App With Kotlin and Android Studio

Chapter 14 Update Persons

In the last chapter we covered the create-part of our CRUD app for persons. In this chapter we now want to enable the user to update existing persons (for example to correct data or change a person’s image). You will notice that our toolset starts to simplify our coding life! Let’s start with a view model for our update page:

14.1 UpdatePersonViewModel.kt

Listing 96: UpdatePersonViewModel.kt
package app.gedama.tutorial.cruddiary.viewmodel
import android.database.sqlite.SQLiteConstraintException
import androidx.lifecycle.*
import app.gedama.tutorial.cruddiary.dao.PersonDao
import app.gedama.tutorial.cruddiary.data.Gender
import app.gedama.tutorial.cruddiary.data.Person
import app.gedama.tutorial.cruddiary.data.Spinnable
import kotlinx.coroutines.launch
import java.util.*
class UpdatePersonViewModel(personId: Long, val personDao: PersonDao) : BaseViewModel() {
    val person = personDao.get(personId)
    val allGenders = personDao.getAllGenders(1L)
    val allGendersAndPersonLiveData: LiveData<Pair<List<Gender>, Person>> =
        object: MediatorLiveData<Pair<List<Gender>, Person>>() {
            var allG: List<Gender>? = null
            var pers: Person? = null
            init {
                addSource( allGenders ) { allG ->
                    this.allG = allG
                    pers?.let { value = allG to it }
                }
                addSource(person) { pers ->
                    this.pers = pers
                    allG?.let { value = it to pers }
                }
            }
        }
    val _newBirthdate = MutableLiveData<Date>()
    val newBirthdate: LiveData<Date>
        get() = _newBirthdate
    fun updatePerson() {
        _doValidation.value = true
    }
    override fun updateEntryAfterValidation() {
        viewModelScope.launch {
            if ( deactivateSuccess.value!! || person.value!!.inactive ) {
                person.value!!.inactive = true
            } else {
                person.value!!.inactive = false
            }
            val newImagePath = checkNewImagePath("person_")
            newImagePath?.let {
                person.value!!.imagePath = it
            }
            try {
                personDao.update(person.value!!)
                _navigateToList.value = true
            } catch(sqlExc: SQLiteConstraintException) {
                _insertSuccess.value = false
                _deactivateSuccess.value = false
            }
        }
    }
    fun deactivatePerson() {
        _deactivateSuccess.value = true
        _doValidation.value = true
    }
    fun updateGender(item: Spinnable) {
        person.value!!.genderId = item.itemId()
    }
    fun updateBirthdate(updatedDate: Date) {
        _newBirthdate.value = updatedDate
        person.value!!.birthDate = updatedDate
    }
}
class UpdatePersonViewModelFactory(private val personId: Long, private val personDao:PersonDao) : ViewModelProvider.Factory {
    override fun <T : ViewModel> create(modelClass: Class<T>): T {
        if (modelClass.isAssignableFrom(UpdatePersonViewModel::class.java)) {
            return UpdatePersonViewModel(personId, personDao) as T
        }
        throw java.lang.IllegalArgumentException("Unknown ViewModel")
    }
}

Analogous to our view model for updating genders, we have a separate Factory-class which is passed a PersonDao object and the ID of the person item that gets updated at initialization. The variable person gets our existing person object from the database. As with our view model for adding new person items, we have to validate the user input and check for database constraint violations.

14.2 string.xml (English)

For our user interface, we have to add a couple of string values to our string.xml files:

Listing 97: 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="diary_entries_list_menu_title">Diary entries</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="unique_gender_violation">This gender name already exists!</string>
    <string name="unique_person_violation">This person 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_gender">Gender 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>
</resources>

Next we define the layout for our new fragment.

14.3 fragment_update_person.xml

Listing 98: fragment_update_person.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.EditPersonFragment">
    <data>
        <variable
            name="viewModel"
            type="app.gedama.tutorial.cruddiary.viewmodel.UpdatePersonViewModel" />
    </data>
    <ScrollView
        android:layout_width="match_parent"
        android:layout_height="match_parent">
        <androidx.appcompat.widget.LinearLayoutCompat
            style="@style/CrudDiary_LinearLayoutCompat">
            <include
                android:id="@+id/name_text_field_binding"
                app:viewModelValue="@={viewModel.person.name}"
                layout="@layout/text_input_name" />
            <include
                android:id="@+id/birthdate_text_field_binding"
                app:hintText="@{@string/person_birthdate_label}"
                app:viewModelValue="@={viewModel.person.birthDateAsString}"
                layout="@layout/text_input_date" />
            <include
                android:id="@+id/spinner_genders_binding"
                app:textHint="@{@string/person_gender_label}"
                layout="@layout/spinner_generic" />
            <include
                android:id="@+id/check_box_binding"
                app:viewModelValue="@={viewModel.person.inactive}"
                layout="@layout/checkbox_inactive" />
            <include
                android:id="@+id/image_area_binding"
                layout="@layout/image_area" />
            <include
                app:viewModelFunc="@{ () -> viewModel.updatePerson() }"
                android:id="@+id/crud_buttons"
                app:visibility="@{android.view.View.VISIBLE}"
                layout="@layout/crud_buttons" />
        </androidx.appcompat.widget.LinearLayoutCompat>
    </ScrollView>
</layout>

Notice the similarity of this file to the one we used for adding new persons! Next we want to extend our navigation graph with our new fragment:

14.4 nav_graph.xml

Listing 99: 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" />
    </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>
</navigation>

Note that we added the argument personId to our new fragment because we have to pass the ID so we know which person item gets updated. Now it is time to present our UpdatePersonFragment class:

14.5 UpdatePersonFragment.kt

Listing 100: UpdatePersonFragment.kt
package app.gedama.tutorial.cruddiary.fragment
import android.app.DatePickerDialog
import android.os.Bundle
import android.util.Log
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.lifecycle.*
import androidx.lifecycle.Observer
import app.gedama.tutorial.cruddiary.R
import androidx.navigation.findNavController
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.FragmentUpdatePersonBinding
import app.gedama.tutorial.cruddiary.ui.LambdaDatePicker
import app.gedama.tutorial.cruddiary.ui.formfields.FormFieldText
import app.gedama.tutorial.cruddiary.viewmodel.UpdatePersonViewModel
import app.gedama.tutorial.cruddiary.viewmodel.UpdatePersonViewModelFactory
import com.google.android.material.snackbar.Snackbar
import java.text.DateFormat
import java.util.*
class UpdatePersonFragment: BaseFragment() {
    private var _binding: FragmentUpdatePersonBinding? = null
    private val binding get() = _binding!!
    lateinit var viewModel: UpdatePersonViewModel
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View {
        _binding = FragmentUpdatePersonBinding.inflate(inflater, container, false)
        val view = binding.root
        val personId = UpdatePersonFragmentArgs.fromBundle(requireArguments()).personId
        val application = requireNotNull(this.activity).application
        val personDao = CrudDiaryDatabase.getInstance(application).personDao
        val viewModelFactory = UpdatePersonViewModelFactory(personId,personDao)
        viewModel = ViewModelProvider(this, viewModelFactory)[UpdatePersonViewModel::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 fieldName = FormFieldText(
            scope = lifecycleScope,
            textInputLayout = binding.nameTextFieldBinding.nameLayout,
            textInputEditText = binding.nameTextFieldBinding.name,
            Validation.nameValidation
        )
        val fieldBirthday = FormFieldText(
            scope = lifecycleScope,
            textInputLayout = binding.birthdateTextFieldBinding.dateLayout,
            textInputEditText = binding.birthdateTextFieldBinding.date,
            Validation.birthdayValidation
        )
        val fieldGender = FormFieldText(
            scope = lifecycleScope,
            textInputLayout = binding.spinnerGendersBinding.spinnerInputLayoutId,
            textInputEditText = binding.spinnerGendersBinding.spinnerTextId,
            Validation.genderValidation
        )
        val formFields = listOf(fieldName,fieldBirthday,fieldGender)
        initImagePicking(binding.imageAreaBinding.imageView, binding.imageAreaBinding.buttonLoadPicture,viewModel, binding.imageAreaBinding.buttonDefaultPicture)
        saveToUpdateButton(binding.crudButtons.actionButton)
        viewModel.navigateToList.observe(viewLifecycleOwner, Observer { navigate ->
            if (navigate) {
                view.findNavController()
                    .navigate(R.id.action_updatePersonFragment_to_personsListFragment)
                viewModel.onNavigatedToList()
            }
        })
        viewModel.allGendersAndPersonLiveData.observe(viewLifecycleOwner, Observer { (allg,pers) ->
            val genderId = pers.genderId ?: 1L
            val currentGender = allg.find{ it.genderId == genderId }
            val currentGenderPos = allg.indexOf(currentGender)
            binding.spinnerGendersBinding.spinnerId.setSelection(currentGenderPos)
        } )
        viewModel.person.observe(viewLifecycleOwner, Observer { item ->
            if ( item.birthDate != null ) {
                binding.birthdateTextFieldBinding.date.setText(DateFormat.getDateInstance(DateFormat.MEDIUM).format(item.birthDate!!))
            }
            setChangeImageButton(view, item.imagePath, viewModel)
            setInactivityButton(item.inactive,binding.crudButtons.deactivateButton,binding.checkBoxBinding.checkBoxInactive)
        })
        viewModel.newBirthdate.observe(viewLifecycleOwner, Observer { item ->
            binding.birthdateTextFieldBinding.date.setText(DateFormat.getDateInstance(DateFormat.MEDIUM).format(item))
        })
        viewModel.doValidation.observe(viewLifecycleOwner, Observer { doValidate ->
            if (doValidate) {
                formValidation(binding.crudButtons.actionButton, formFields, viewModel)
            }
        })
        viewModel.insertSuccess.observe(viewLifecycleOwner, Observer { isSuccess ->
            if (isSuccess != null && !isSuccess ) {
                Snackbar.make(binding.crudButtons.actionButton,resources.getText(R.string.unique_person_violation),
                    Snackbar.LENGTH_INDEFINITE).show()
                Log.d(TAG,"There was a problem when updating person data in the database ...")
            }
        })
        @Suppress("UNCHECKED_CAST")
        initMaterialSpinnerData(binding.spinnerGendersBinding.spinnerId,binding.spinnerGendersBinding.spinnerTextId,
            viewModel.allGenders as LiveData<List<Spinnable>>,{ item: Spinnable -> viewModel.updateGender(item)} )
        binding.birthdateTextFieldBinding.date.setOnClickListener(handleIconClick)
        binding.birthdateTextFieldBinding.dateLayout.setEndIconOnClickListener(handleIconClick)
        setDeleteImageButton(binding.imageAreaBinding.buttonDefaultPicture,binding.imageAreaBinding.imageView,
            binding.imageAreaBinding.imageViewContainer,binding.imageAreaBinding.buttonLoadPicture, viewModel)
        setDeactivateButton(binding.crudButtons.deactivateButton, { viewModel.deactivatePerson() } )
        return view
    }
    override fun onDestroyView() {
        super.onDestroyView()
        _binding = null
    }
    private val handleIconClick = object : View.OnClickListener {
        override fun onClick(v: View?) {
            val picker = LambdaDatePicker(context!!, showTimePicker = false, { item: Date -> viewModel.updateBirthdate(item) })
            val currentBirthdate = Calendar.getInstance()
            viewModel.person.value?.birthDate?.let {
                currentBirthdate.time = viewModel.person.value!!.birthDate!!
            }
            val datePickerDialog =
                DatePickerDialog(
                    context!!,
                    picker,
                    currentBirthdate.get(Calendar.YEAR),
                    currentBirthdate.get(
                        Calendar.MONTH
                    ),
                    currentBirthdate.get(Calendar.DAY_OF_MONTH)
                )
            datePickerDialog.datePicker.maxDate = Date().time
            datePickerDialog.show()
        }
    }
}

Once again, for getting from the persons list page to the ’Update person’ page, we have to extend our existing PersonsListFragment:

14.6 PersonsListFragment.kt

Listing 101: PersonsListFragment.kt
...
        viewModel.navigateToAddItem.observe(viewLifecycleOwner, Observer { navigate ->
            if ( navigate ) {
                val action = PersonsListFragmentDirections
                    .actionPersonsListFragmentToAddPersonFragment()
                this.findNavController().navigate(action)
                viewModel.onItemAddNavigated()
            }
        })
        viewModel.navigateToEditItem.observe(viewLifecycleOwner, Observer { personId ->
            personId?.let {
                val action = PersonsListFragmentDirections
                    .actionPersonsListFragmentToUpdatePersonFragment(personId)
                this.findNavController().navigate(action)
                viewModel.onItemEditNavigated()
            }
        })
        return view
...

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 one of the items in the persons list, you get to the input mask for updating the selected person (see Figure 14.1). Notice the image buttons below Lucy’s picture: We have a ’Change image’ button and a ’Delete image’ button.

Update person
Figure 14.1: Update person

If you tap onto the ’Delete image’ button, the existing picture vanishes and we end up with a single button called ’Add image’ (see Figure 14.2).

Update person
Figure 14.2: Update person

If the validation of our updated person was successful, the logic returns to the persons list and displays the updated person values (alternate picture, birthdate was changed - see Figure 11.2).

After updating Lucy’s image
Figure 14.3: After updating Lucy’s image