Codelab — Kotlin Tips calculator

  • Solution

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.

  • Convert the MainActivity.java class into Kotlin
  • Gradle
In my case, the shortcut is CtrL + Alt + ⇧ Shift + K

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.

  • 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
data class TipsEntry(var amount: Float = 0F, var percent : Int = 18)
val tips : Float
get() = amount / 100 * percent
val total
: Float
get() = amount + tips
  • public float getTotal() { ... }

Adding the RecyclerView

Explanation

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

  • 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 :
<?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"
/>
<?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>

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.

  • Display only one item
  • Use the layout tips_entry_item to display our row
  • Assign the newly created TipsAdapter to the RecyclerView in MainActivity
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)
}
findViewById<RecyclerView>(R.id.rv_entries).adapter = TipsAdapter(this)

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.

  • 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
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}%"
}
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) {

}
}
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) {

}
}

Allow multiple amounts

Explanation

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

  • When an amount is removed, delete the row
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)
}
}
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) {

}

Showing total amount

Explanation

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

  • Create a ViewHolder for this layout called TotalViewHolder
  • Use the newly created ViewHolder in the adapter to display the total
<?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>
class TotalViewHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {

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

fun bind(total: Double) {
tvTotal.text = "%.2f".format(total)
}
}
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() }
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)
}
}
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
entry.percent = position + 1
setTips()
adapter.notifyItemChanged(adapter.itemCount - 1)
}

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.

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

}

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

}

}
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?) {

}

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

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.

  • 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 :)

--

--

Android developer by day, procrastinator by night

Love podcasts or audiobooks? Learn on the go with our new app.

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store
Benjamin Monjoie

Benjamin Monjoie

Android developer by day, procrastinator by night