Project 3: Unit Converter with Dropdown Menus

Android Development with Kotlin: 21 Real Projects · Building Real Apps · 8 min read · Updated

Convert through a base unit

  1. The user picks 5 kilometres and a target unit of miles.
  2. toBase turns kilometres into metres: 5000.
  3. fromBase turns metres into miles using the miles factor.
  4. The screen shows the number, about 3.107.

The unit converter is a small app with a clear job: turn 5 kilometres into miles, or 100 degrees Celsius into Fahrenheit. It is useful for learning because it forces you to think about data design. How do you store dozens of units without writing a huge if chain? The answer is a table of data, and it is a lesson you will reuse in every later project: when you feel yourself copying code, describe the differences as data instead.

Concepts in this project

  • Data-driven design: each unit is a small object with two functions, toBase and fromBase. To convert A to B, change A to the base unit, then change the base unit to B. With N units you write N pairs of formulas, not N times N.
  • Dropdown menus: ExposedDropdownMenuBox or a DropdownMenu anchored to a button, with items that call a lambda when chosen.
  • Validation: the input is text, so it may be empty or invalid. The code must show a clear message and never crash.
  • Pure functions: the conversion has no Android code, so you can test it with no phone.

Create the project

  1. New Empty Activity project Converter, package com.example.converter. No extra libraries are needed.
  2. Add Units.kt and ConverterScreen.kt, and replace MainActivity.kt.
Converter/app/src/main/java/com/example/converter/
  MainActivity.kt      shows ConverterScreen
  Units.kt             unit definitions and the convert function
  ConverterScreen.kt   input, two pickers, result

The code

`app/src/main/java/com/example/converter/Units.kt`

package com.example.converter

/** Every unit knows how to turn itself into a base unit and back. */
data class UnitDef(val name: String, val toBase: (Double) -> Double, val fromBase: (Double) -> Double)

data class Category(val name: String, val units: List<UnitDef>)

private fun linear(name: String, factor: Double) = UnitDef(name, { it * factor }, { it / factor })

val categories = listOf(
    Category(
        "Length (base: metre)",
        listOf(linear("Metre", 1.0), linear("Kilometre", 1000.0), linear("Centimetre", 0.01),
            linear("Mile", 1609.344), linear("Foot", 0.3048), linear("Inch", 0.0254))
    ),
    Category(
        "Weight (base: kilogram)",
        listOf(linear("Kilogram", 1.0), linear("Gram", 0.001), linear("Pound", 0.45359237), linear("Ounce", 0.028349523125))
    ),
    Category(
        "Temperature (base: Celsius)",
        listOf(
            UnitDef("Celsius", { it }, { it }),
            UnitDef("Fahrenheit", { (it - 32) * 5 / 9 }, { it * 9 / 5 + 32 }),
            UnitDef("Kelvin", { it - 273.15 }, { it + 273.15 })
        )
    )
)

fun convert(value: Double, from: UnitDef, to: UnitDef): Double = to.fromBase(from.toBase(value))

Look at temperature. Celsius is the base unit, so toBase for Fahrenheit is (f - 32) * 5 / 9 and fromBase is c * 9 / 5 + 32. Length and weight units just multiply by a factor. Because every unit follows the same shape, the screen needs no special cases at all.

`app/src/main/java/com/example/converter/ConverterScreen.kt`

package com.example.converter

import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import java.util.Locale

@Composable
fun ConverterScreen() {
    var categoryIndex by remember { mutableIntStateOf(0) }
    var fromIndex by remember { mutableIntStateOf(0) }
    var toIndex by remember { mutableIntStateOf(1) }
    var input by remember { mutableStateOf("1") }

    val category = categories[categoryIndex]
    val from = category.units[fromIndex]
    val to = category.units[toIndex]

    val number = input.toDoubleOrNull()
    // derivedStateOf would also work here; a plain value is fine because it is cheap.
    val output = number?.let { convert(it, from, to) }

    Column(
        Modifier.fillMaxSize().systemBarsPadding().padding(20.dp),
        verticalArrangement = Arrangement.spacedBy(14.dp)
    ) {
        Text("Unit Converter", style = MaterialTheme.typography.headlineMedium)

        Picker(label = "Category", options = categories.map { it.name }, selected = categoryIndex) {
            categoryIndex = it; fromIndex = 0; toIndex = 1   // reset units when the category changes
        }
        OutlinedTextField(
            value = input,
            onValueChange = { input = it },
            label = { Text("Value") },
            isError = number == null && input.isNotEmpty(),
            supportingText = { if (number == null && input.isNotEmpty()) Text("Enter a number") },
            singleLine = true,
            keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal),
            modifier = Modifier.fillMaxWidth()
        )
        Picker("From", category.units.map { it.name }, fromIndex) { fromIndex = it }
        Picker("To", category.units.map { it.name }, toIndex) { toIndex = it }

        OutlinedButton(onClick = { val t = fromIndex; fromIndex = toIndex; toIndex = t }) { Text("Swap units") }

        Card(Modifier.fillMaxWidth()) {
            Text(
                text = if (output == null) "-" else "${format(number!!)} ${from.name} = ${format(output)} ${to.name}",
                style = MaterialTheme.typography.titleMedium,
                modifier = Modifier.padding(16.dp)
            )
        }
    }
}

@Composable
private fun Picker(label: String, options: List<String>, selected: Int, onSelect: (Int) -> Unit) {
    var expanded by remember { mutableStateOf(false) }
    Row(verticalAlignment = Alignment.CenterVertically) {
        Text("$label:", modifier = Modifier.width(90.dp))
        Box {
            OutlinedButton(onClick = { expanded = true }) { Text(options[selected]) }
            DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
                options.forEachIndexed { index, name ->
                    DropdownMenuItem(text = { Text(name) }, onClick = { onSelect(index); expanded = false })
                }
            }
        }
    }
}

private fun format(v: Double): String = String.format(Locale.US, "%.4f", v).trimEnd('0').trimEnd('.')

`app/src/main/java/com/example/converter/MainActivity.kt`

package com.example.converter

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface

class MainActivity : ComponentActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        enableEdgeToEdge()
        setContent { MaterialTheme { Surface { ConverterScreen() } } }
    }
}

How it works

  • The category chips choose a list of units. When the category changes, both pickers are reset to sensible units from the new list.
  • Picker is a reusable composable: it shows the current unit, opens a menu when tapped and reports the choice through onPick. Its own expanded state is local, because nobody else cares whether a menu is open.
  • The result is recomputed from the typed text. If the text is not a number, no result is shown instead of an error.

Expected output

Choose Length, type 5, from Kilometres to Miles: the result reads about 3.107. Choose Temperature, type 100, from Celsius to Fahrenheit: 212. Type 0 from Celsius to Fahrenheit: 32. Type 72 from Fahrenheit to Celsius: about 22.22. Swapping the two units gives the reverse conversion.

Your task

  1. Build the app and check the four example conversions.
  2. Add a swap button between the two pickers that exchanges the units.
  3. Add a new category, Speed, with metres per second, kilometres per hour and miles per hour.
  4. Round the output to four decimal places with "%.4f".format(value).

How to check it worked

  • Every example above matches.
  • Typing letters or leaving the box empty never crashes.
  • Adding Speed needed changes to the data only, not the screen.

Frequently asked questions

Why convert through a base unit?

It needs only two formulas per unit. Converting directly between every pair of units would need a formula for each pair, which grows very fast.

How do I keep the menu state?

Keep it local with remember when only that composable needs it. Lift it up only if another part of the screen must read or control it.

Is Double accurate enough for money?

No. Double is fine for measurements, but money should be stored as whole cents in a Long or in BigDecimal. Project 7 does exactly that.