Codelab — Kotlin Tips calculator

Benjamin Monjoie
13 min readAug 12, 2017

--

This codelab has been created for a crash course organized by the Belgium Kotlin User Group, GDG Brussels and GDG Namur Android in Brussels on the 12th of August 2017.

This codelab will take you through the creation of a very simple android application used to calculate tips. The end result should look like this :

You will find the base project in a git repository and each step is committed into a branch for you to ensure that everything is going as intended or find answers.

Each step is going to be decomposed into two :

  • Explanation
  • Solution

Depending on your skills in both Kotlin and Android, You can either only read the explanation of what is going to be achieved and try to do it yourself or go through the solution directly.

Requirements

You need installed on your machine :

Let’s begin

Adding Kotlin

Explanation

The base project has been created as a Java Android application to show you the steps of how to add Kotlin to an existing project.

You are asked to :

  • Configure Kotlin in the project
  • Convert the MainActivity.java class into Kotlin

Configure Kotlin in the project :
Open the menu “Tools -> Kotlin -> Configure Kotlin in project” as shown on the picture below :

A small window appears and ask you to choose between :

  • Android with Gradle
  • Gradle

Choose “Android with Gradle” , then the final window appears and ask you for which modules you want to configure it :

Leave as is and click on “OK” .

It is possible that Android Studio won’t be able to retrieve the list of Kotlin versions from the internet, if that’s the case, you’ll see a warning icon and the runtime version will be 1.0.0 as shown on the screenshot above. To use the latest version anyway, open the file build.gradle which is at the root of you project and change the following line ext.kotlin_version = '1.0.0' to ext.kotlin_version = '1.1.3-2' .

Note that you will need an internet connection, at least the first time, to download the dependencies and be able to compile.

You should see a yellow banner indicating that some gradle files have changed and that Android Studio needs to synchronize. Click on the underlined “Sync now” on the right.

Convert the MainActivity.java class into Kotlin :
Open app/src/main/java/com.appkers.tipscalculator_codelab.MainActivity.java , use the find action dialog (Ctrl + ⇧ Shift + A or, on Apple, ⌘ + ⇧ Shift + A) to find the action “Convert Java File to Kotlin File” and select it.

Pro-tip : remember the shortcut that is shown in the dialog

In my case, the shortcut is CtrL + Alt + ⇧ Shift + K

The class should have been converted to Kotlin and look like the file MainActivity.kt in the branch step-1.

Launch the application and everything should look like exactly as it did previously.

Creating a data class

Explanation

In this section, we will create a Kotlin data class that will holds the information for each rows of our UIs.

You are asked to :

  • Create a data class called TipsEntry in the package com.appkers.tipscalculator_codelab.entities with the following mutable properties :
    - amount as Float. By default, the amount value is going to be 0
    - percent as Int. By default, the tips is going to be 18%
  • Add a way to retrieve the value of the tips based on the amount and the percentage.
    E.g.: if the amount is 50 and the percentage is 10, tips will return 5
  • Add a way to retrieve the total based on the amount and the tips.
    E.g.: if the amount is 50 and the percentage is 10, total will return 55

Create a data class :
Create a new package inside “com.appkers.tipscalculator_codelab” called “entities” and create a new Kotlin class by right clicking on the newly created “entities” package than select “New -> Kotlin File/Class”. A dialog opens, enter “TipsEntry” as name and select “Class” for kind. Modify the code to make it look like the following :

data class TipsEntry(var amount: Float = 0F, var percent : Int = 18)

This class represents a new entry in our UI to which a amount and a tip percentage is associated.

Add a way to retrieve the value of the tips and total :
Now, we can add two properties to return the tips amount and the total :

val tips : Float
get() = amount / 100 * percent
val total
: Float
get() = amount + tips

If you are used to Java, you would have probably created two methods :

  • public float getTips() { ... }
  • public float getTotal() { ... }

Nevertheless, in Kotlin, properties are first class citizens and allow us to reduce the boilerplate code. It is now possible to retrieve the tips by calling entry.tips and the total amount by callingentry.total instead of entry.getTips() and entry.getTotal() reducing the amount of code we have to write.

You can find the final class in the branch step-2.

Adding the RecyclerView

Explanation

Now that we have our data class to represent a new entry, we should add the corresponding UI.

You are asked to :

  • Add the dependency to RecyclerView
  • Change the layout of MainActivity to use RecyclerView (creating the adapter will be next step)
  • Create a layout for our rows called tips_entry_item that will look like this :

Don’t forget that the EditText should only allow decimal numbers as input !

Add the dependency to RecyclerView:
Open the file app/build.gradle and add in dependencies, around the end of the file, the following line : implementation 'com.android.support:recyclerview-v7:26.0.0-beta2'

Synchronize your changes once again.

Change the layout of MainActivity:
Open the file app/src/main/res/layout/activity_main.xml and change it with the following content :

<?xml version="1.0" encoding="utf-8"?>
<android.support.v7.widget.RecyclerView
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/rv_entries"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="android.support.v7.widget.LinearLayoutManager"
tools:listitem="@layout/tips_entry_item"
/>

You may have noticed that I already have added the following line app:layoutManager="android.support.v7.widget.LinearLayoutManager to my layout. This allows me not to define the LayoutManager in code but in the XML and may be very useful in case we want a different layout in landscape or for tablet, etc.
Also, I’m using tools:listitem="@layout/tips_entry_item which is going to fill my preview to help visualize the end result.

Create a layout for our rows :
Create new file app/src/main/res/layout/tips_entry_item.xml with the following content :

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
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:layout_width="match_parent"
android:layout_height="wrap_content"
>

<EditText
android:id="@+id/etAmount"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:layout_marginRight="8dp"
android:layout_marginEnd="8dp"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:hint="Enter amount"
android:inputType="numberDecimal"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/spPercent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0"
tools:text="5"
/>

<TextView
android:id="@+id/tvTips"
android:layout_width="50dp"
android:layout_height="wrap_content"
android:textAlignment="center"
android:layout_marginEnd="16dp"
android:layout_marginStart="16dp"
app:layout_constraintBottom_toBottomOf="@+id/spPercent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toEndOf="@+id/spPercent"
app:layout_constraintTop_toTopOf="@+id/spPercent"
app:layout_constraintVertical_bias="0.6"
tools:text="0.56$"
/>

<Spinner
android:id="@+id/spPercent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginLeft="16dp"
android:layout_marginStart="16dp"
app:layout_constraintBottom_toBottomOf="@+id/etAmount"
app:layout_constraintEnd_toStartOf="@+id/tvTips"
app:layout_constraintStart_toEndOf="@+id/etAmount"
app:layout_constraintTop_toTopOf="@+id/etAmount"
app:layout_constraintVertical_bias="0.785"
/>
</android.support.constraint.ConstraintLayout>

The line android:inputType="numberDecimal" will enforce Android to only accept decimal numbers in the EditText.
We are also using tools:text="..." to fill our preview and make it easier to visualize.

The result can be found in the branch step-3.

Creating an adapter

Explanation

You may have noticed, if you have launched the application, that nothing is shown. That is because we need to create an adapter. For now, we just want to show one entry to allow people to enter a new amount and calculate the tips.

You are asked to :

  • Create an adapter called TipsAdapter in the package com.appkers.tipscalculator_codelab.adapters
  • Display only one item
  • Use the layout tips_entry_item to display our row
  • Assign the newly created TipsAdapter to the RecyclerView in MainActivity

The result should look like this :

Create an adapter called TipsAdapter:
Create a new package called adapters, then create a new Kotlin class called TipsAdapter and paste the following code into it :

class TipsAdapter(context: Context) : RecyclerView.Adapter<TipsAdapter.ViewHolder>() {

val layoutInflater = LayoutInflater.from(context)

override fun getItemCount() = 1

override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int) : ViewHolder {
return ViewHolder(layoutInflater.inflate(R.layout.tips_entry_item, parent, false))
}

override fun onBindViewHolder(holder: ViewHolder?, position: Int) {

}

class ViewHolder(v: View) : RecyclerView.ViewHolder(v)
}

Assign the newly created TipsAdapter to the RecyclerView in MainActivity :
At the end of the method onCreate inside MainActivity.kt , add the following line :

findViewById<RecyclerView>(R.id.rv_entries).adapter = TipsAdapter(this)

Launch the application and you should see the result shown in the explanation section.

The code can be found in the branch step-4.

Select the tip percentage

Explanation

You probably noticed nothing is happening when you enter a value or click on the arrow. It’s normal as we have yet to include any logic.

First, we’ll focus on selecting the tip percentage and showing the amount based on the value entered.

You are asked to :

  • Fill the Spinner with values displaying percentage from 1% to 100%
  • Extract the logic of the ViewHolder into its own class called TipsViewHolder in the package com.appkers.tipscalculator_codelab.adapters.vh
  • Display the tips when either the amount or the percentage is changed

Fill the Spinner:
Create a new Kotlin class called PercentAdapter in the package adapters and paste the following into it :

class PercentAdapter(context: Context) : ArrayAdapter<String>(context, android.R.layout.simple_list_item_1) {

override fun getCount() = 100

override fun getItem(position: Int) = "${position + 1}%"
}

We assign this adapter to the Spinner in the TipsViewHolder.

Extract the logic of the ViewHolder into its own class and display the tips when either the amount or the percentage is changed :
Now, create a new package called vh under the page adapters and create a new Kotlin class called TipsViewHolder and paste the following into it :

class TipsViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView), AdapterView.OnItemSelectedListener, TextWatcher {

val etAmount: EditText = itemView.findViewById<EditText>(R.id.etAmount)
val tvTips: TextView = itemView.findViewById<TextView>(R.id.tvTips)
val spPercent: Spinner = itemView.findViewById<Spinner>(R.id.spPercent)

init {
spPercent.adapter = PercentAdapter(itemView.context)
spPercent.onItemSelectedListener = this
etAmount
.addTextChangedListener(this)
}

override fun afterTextChanged(p0: Editable?) {
setTips()
}

override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
setTips()
}

private fun setTips() {
if (etAmount.text.toString().isNotEmpty()) {
val percent = spPercent.selectedItemPosition + 1
val amount = etAmount.text.toString().toFloat()
tvTips.text = TipsEntry(amount, percent).tips.toString()
}
}

override fun onNothingSelected(p0: AdapterView<*>?) {

}

override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {

}

override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {

}
}

and change the TipsAdapter to use our newly created TipsViewHolder :

class TipsAdapter(context: Context) : RecyclerView.Adapter<TipsViewHolder>() {

val layoutInflater = LayoutInflater.from(context)

override fun getItemCount() = 1

override fun onCreateViewHolder(parent: ViewGroup?, viewType: Int) : TipsViewHolder {
return TipsViewHolder(layoutInflater.inflate(R.layout.tips_entry_item, parent, false))
}

override fun onBindViewHolder(holder: TipsViewHolder?, position: Int) {

}
}

You’ll notice that if you enter an amount and select a percentage, the tips amount is shown.

The result of this code can be found in the branch step-5.

Allow multiple amounts

Explanation

Now, we would like to be able to insert multiple amount.

You are asked :

  • When a new amount is entered, add a new empty row
  • When an amount is removed, delete the row

This is probably the biggest and hardest part of the project. This requires knowledge of how RecyclerView’s adapters work. If you are not familiar enough with this, do not hesitate to look at the solution.

Modify the adapter as follow :

class TipsAdapter(context: Context) : RecyclerView.Adapter<TipsViewHolder>() {

val layoutInflater = LayoutInflater.from(context)

val list = ArrayList<TipsEntry>()

override fun getItemCount() = list.size + 1

override fun onCreateViewHolder(parent: ViewGroup?, type: Int) : TipsViewHolder {
return TipsViewHolder(layoutInflater.inflate(R.layout.tips_entry_item, parent, false), this)
}

override fun onBindViewHolder(holder: TipsViewHolder, position: Int) {
holder.bind(if (position < list.size) list[position] else TipsEntry())
}

fun setAmountFor(entry: TipsEntry, position: Int) {
if (position >= list.size) {
list.add(position, entry)
notifyItemInserted(position + 1)
}
}

fun removeAmountAt(adapterPosition: Int) {
if (adapterPosition < list.size) {
list.removeAt(adapterPosition)
notifyItemRemoved(adapterPosition)
}
}

And TipsViewHolder as follow :

val etAmount: EditText = itemView.findViewById<EditText>(R.id.etAmount)
val tvTips: TextView = itemView.findViewById<TextView>(R.id.tvTips)
val spPercent: Spinner = itemView.findViewById<Spinner>(R.id.spPercent)

init {
etAmount.addTextChangedListener(this)
spPercent.adapter = PercentAdapter(itemView.context)
spPercent.onItemSelectedListener = this
}

private lateinit var entry: TipsEntry

fun bind(entry: TipsEntry) {
this.entry = entry
spPercent.setSelection(entry.percent - 1)
etAmount.setText((if (entry.amount > 0) {
entry.amount.toString()
} else {
""
}))
setTips()
}

private fun setTips() {
tvTips.text = if (entry.amount > 0) String.format("%.2f", entry.tips) else ""
}

override fun afterTextChanged(s: Editable?) {
if (s != null && !s.isEmpty()) {
entry.amount = s.toString().toFloat()
adapter.setAmountFor(entry, adapterPosition)
setTips()
} else {
adapter.removeAmountAt(adapterPosition)
}
}

override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
entry.percent = position + 1
setTips()
}

override fun onNothingSelected(p0: AdapterView<*>?) {

}

override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {

}

override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {

}

As you can see, now, whenever you enter a new amount, a new line is automatically added below.

The result of this code can be found in branch step-6.

Showing total amount

Explanation

Now, we want to show the total amount of all the rows in our RecyclerView.

You are asked to :

  • Create a new layout called tips_total_item to display the total amount
  • Create a ViewHolder for this layout called TotalViewHolder
  • Use the newly created ViewHolder in the adapter to display the total

Create a new layout to display the total :
Create a new file app/src/main/res/layout/tips_total_item with the following content :

<?xml version="1.0" encoding="utf-8"?>
<android.support.constraint.ConstraintLayout
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:layout_width="match_parent"
android:layout_height="match_parent"
>

<TextView
android:id="@+id/textView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginLeft="16dp"
android:layout_marginTop="16dp"
android:text="Total"
android:textAppearance="?android:attr/textAppearanceMedium"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:layout_editor_absoluteX="7dp"
tools:layout_editor_absoluteY="16dp"
/>

<TextView
android:id="@+id/tvTotal"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textStyle="bold"
android:textAppearance="?android:attr/textAppearanceMedium"
android:layout_marginEnd="16dp"
android:layout_marginRight="16dp"
android:layout_marginStart="16dp"
android:layout_marginLeft="16dp"
tools:text="126"
app:layout_constraintBaseline_toBaselineOf="@+id/textView"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/textView"
/>
</android.support.constraint.ConstraintLayout>

Create a ViewHolder for this layout :
Create a new ViewHolder for the total under adapters.vh and call it TotalViewHolder with the following content :

class TotalViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

val tvTotal = itemView.findViewById<TextView>(R.id.tvTotal)

fun bind(total: Double) {
tvTotal.text = "%.2f".format(total)
}
}

Use the newly created ViewHolder in the adapter to display the total :
Since we now have 2 different ViewHolders, we have to change the generics of TipsAdapter because we can’t use TipsViewHolder as generic since TotalViewHolder doesn’t extend TipsViewHolder . We could make an interface or an abstract class but we’ll see soon that we can use the power of Kotlin avoid any boilerplate.

So, first, change TipsAdapter to extends RecyclerView.Adapter<RecyclerView.ViewHolder>()

Finally, change TipsAdapter ‘s method as shown below :

override fun getItemCount() = list.size + 2

override fun onCreateViewHolder(parent: ViewGroup?, type: Int) = when (type) {
R.layout.tips_entry_item -> TipsViewHolder(layoutInflater.inflate(type, parent, false), this)
else -> TotalViewHolder(layoutInflater.inflate(type, parent, false))
}

override fun getItemViewType(position: Int) = when (position) {
list.size + 1 -> R.layout.tips_total_item
else -> R.layout.tips_entry_item
}

override fun onBindViewHolder(holder: RecyclerView.ViewHolder?, position: Int) = when (holder) {
is TipsViewHolder -> holder.bind(if (position < list.size) list[position] else TipsEntry())
is TotalViewHolder -> holder.bind(getTotal())
else -> throw IllegalArgumentException("Unknown holder type")
}

fun getTotal() = list.sumByDouble { it.total.toDouble() }

Look at onBindViewHolder , we are using when to act depending on the type of the ViewHolder and Kotlin’s smart cast to call directly the correct method bind on the ViewHolder.

We also need to change the following method of TipsAdapter to warn that the total has changed :

fun setAmountFor(entry: TipsEntry, position: Int) {
if (position >= list.size) {
list.add(position, entry)
notifyItemInserted(position + 1)
}
notifyItemChanged(itemCount - 1)
}

fun removeAmountAt(adapterPosition: Int) {
if (adapterPosition < list.size) {
list.removeAt(adapterPosition)
notifyItemRemoved(adapterPosition)
notifyItemChanged(itemCount - 1)
}
}

And add a similar line in TipsViewHolder when the percentage has changed :

override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
entry.percent = position + 1
setTips()
adapter.notifyItemChanged(adapter.itemCount - 1)
}

The application is now complete and should work as expected.

You can find the result in the branch step-7.

Final improvement (Bonus)

One last tidbit for the road, TipsViewHolder has 3 empty methods which is polluting our code. To remove them, let’s delegate those calls.

Create a new package common.empties and add the two following classes :

DefaultOnItemSelectedListener :

class DefaultOnItemSelectedListener : AdapterView.OnItemSelectedListener {
override fun onNothingSelected(p0: AdapterView<*>?) {

}

override fun onItemSelected(p0: AdapterView<*>?, p1: View?, p2: Int, p3: Long) {

}

}

DefaultTextWatcher :

class DefaultTextWatcher : TextWatcher {

override fun beforeTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {

}

override fun onTextChanged(p0: CharSequence?, p1: Int, p2: Int, p3: Int) {

}

override fun afterTextChanged(p0: Editable?) {

}

}

Change the first line of TipsViewHolder to look like this :

class TipsViewHolder(itemView: View, val adapter: TipsAdapter) : RecyclerView.ViewHolder(itemView), AdapterView.OnItemSelectedListener by DefaultOnItemSelectedListener(), TextWatcher by DefaultTextWatcher() {

and remove the empty methods. Voilà !

The result of this can be found in the branch step-8.

If you did everything thing right, you should have something like this :

Conclusion

There are still a lot of functionalities to learn in Kotlin but I hope this codelab has highlighted how you can create an app from scratch and how Kotlin can help speed up the process.

If you wish to go further, here are a few ideas :

  • Create a sealed class and use it as TipsAdapter ‘s generic to remove the else in the when of onBindViewHolder
  • Create a preference activity to set the default value of the percentage instead of using 18%
  • Add a save button to insert the current rows in a database and retrieve them from a history list
  • The sky is the limit :)

--

--

Benjamin Monjoie
Benjamin Monjoie

Written by Benjamin Monjoie

Android developer by day, procrastinator by night

Responses (2)